225 lines
7.4 KiB
TypeScript
225 lines
7.4 KiB
TypeScript
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);
|
|
}
|
|
}
|