Added utils package for parsing helpers

This commit is contained in:
2026-03-12 18:00:52 +01:00
parent c33f6f781c
commit 3de4ec28b4
5 changed files with 417 additions and 0 deletions

View File

@@ -15,5 +15,12 @@ export default defineConfig([
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, globals: globals.browser,
}, },
rules: {
"@typescript-eslint/ban-ts-comment": [
"error", {
"ts-ignore": "allow-with-description"
}
],
}
}, },
]) ])

View File

@@ -1,5 +1,6 @@
{ {
"name": "@hamradio/packet", "name": "@hamradio/packet",
"type": "module",
"version": "1.0.0", "version": "1.0.0",
"description": "Low level packet parsing library (for radio protocols)", "description": "Low level packet parsing library (for radio protocols)",
"keywords": [ "keywords": [

View File

@@ -770,3 +770,25 @@ export class Writer {
} }
export { FieldType } from './types'; export { FieldType } from './types';
export {
isBytes,
assertBytes,
bytesToHex,
hexToBytes,
bytesToBase64,
base64ToBytes,
U8,
U16,
U32,
I8,
I16,
I32,
F32,
F64,
type Encoding,
type HexEncoding,
type Base64Encoding,
type Base64RawEncoding,
type Base64URLEncoding,
type Base64RawURLEncoding,
} from './utils';

224
src/utils.test.ts Normal file
View File

@@ -0,0 +1,224 @@
import { describe, expect, it } from 'vitest';
import {
U8,
U16,
U32,
I8,
I16,
I32,
F32,
F64,
isBytes,
assertBytes,
bytesToHex,
hexToBytes,
bytesToBase64,
base64ToBytes,
decodeBytes,
encodeBytes,
} from './utils';
/* eslint-disable @typescript-eslint/no-explicit-any */
describe('utils', () => {
const staticHexVectors = [
{ bytes: Uint8Array.from([]), hex: '' },
{ bytes: Uint8Array.from([0xbe]), hex: 'be' },
{ bytes: Uint8Array.from([0xca, 0xfe]), hex: 'cafe' },
{ bytes: Uint8Array.from(new Array(1024).fill(0x69)), hex: '69'.repeat(1024) },
];
const buf = new ArrayBuffer(8);
const v8 = new Uint8Array(buf);
for (let i = 0; i < v8.length; i++) v8[i] = i + 1;
it('U8 from ArrayBuffer', () => {
const a8 = U8(buf);
expect(a8).toBeInstanceOf(Uint8Array);
expect(Array.from(a8)).toEqual([1,2,3,4,5,6,7,8]);
});
it('U8 from Uint8Array view', () => {
const sub = new Uint8Array(buf, 2, 4);
const s8 = U8(sub);
expect(Array.from(s8)).toEqual([3,4,5,6]);
});
it('U16 from ArrayBuffer', () => {
const a16 = U16(buf);
expect(a16).toBeInstanceOf(Uint16Array);
expect(a16.length).toBe(Math.floor(buf.byteLength / 2));
});
it('U16 from Uint8Array view', () => {
const sub = new Uint8Array(buf, 2, 4);
const s16 = U16(sub);
expect(s16.length).toBe(Math.floor(sub.byteLength / 2));
});
it('U32 from ArrayBuffer', () => {
const a32 = U32(buf);
expect(a32).toBeInstanceOf(Uint32Array);
expect(a32.length).toBe(Math.floor(buf.byteLength / 4));
});
it('U32 from Uint8Array view', () => {
const sub = new Uint8Array(buf, 4, 4);
const s32 = U32(sub);
expect(s32.length).toBe(Math.floor(sub.byteLength / 4));
});
it('I8 from ArrayBuffer and view', () => {
const buf = new ArrayBuffer(4);
const dv = new DataView(buf);
dv.setInt8(0, -5);
dv.setInt8(1, 120);
dv.setInt8(2, -128);
dv.setInt8(3, 127);
const a = I8(buf);
expect(Array.from(a)).toEqual([-5, 120, -128, 127]);
const sub = new Int8Array(buf, 1, 2);
const s = I8(sub);
expect(Array.from(s)).toEqual([120, -128]);
});
it('I16 from ArrayBuffer and misaligned view throws', () => {
const buf = new ArrayBuffer(6);
const dv = new DataView(buf);
dv.setInt16(0, -12345, true);
dv.setInt16(2, 12345, true);
dv.setInt16(4, 32767, true);
const a16 = I16(buf);
expect(a16.length).toBe(3);
expect(a16[0]).toBe(-12345);
expect(a16[1]).toBe(12345);
expect(a16[2]).toBe(32767);
const view = new Uint8Array(buf, 1, 4);
expect(() => I16(view)).toThrow();
});
it('I32 from ArrayBuffer and misaligned view throws', () => {
const buf = new ArrayBuffer(12);
const dv = new DataView(buf);
dv.setInt32(0, -0x1234567, true);
dv.setInt32(4, 0x1234567, true);
const a32 = I32(buf);
expect(a32[0]).toBe(-0x1234567);
expect(a32[1]).toBe(0x1234567);
const view = new Uint8Array(buf, 1, 8);
expect(() => I32(view)).toThrow();
});
it('F32/F64 from ArrayBuffer', () => {
const buf = new ArrayBuffer(16);
const dv = new DataView(buf);
dv.setFloat32(0, 3.14, true);
dv.setFloat32(4, -2.5, true);
dv.setFloat64(8, 1.23456789e3, true);
const f32 = F32(buf);
expect(f32[0]).toBeCloseTo(3.14, 5);
expect(f32[1]).toBeCloseTo(-2.5, 5);
const f64 = F64(buf);
expect(f64[1]).toBeCloseTo(1.23456789e3, 8);
});
it('isBytes', () => {
expect(isBytes(new Uint8Array([1,2]))).toBe(true);
expect(isBytes(new Uint16Array([1,2]) as unknown)).toBe(false);
expect(isBytes(new ArrayBuffer(2) as unknown)).toBe(false);
})
it('assertBytes', () => {
// assertBytes accepts a Uint8Array
expect(() => assertBytes(new Uint8Array([1,2]))).not.toThrow();
// assertBytes with length mismatch throws
expect(() => assertBytes(new Uint8Array([1,2]), 3)).toThrow();
// assertBytes with wrong type throws
expect(() => assertBytes(new Uint16Array([1,2]) as any)).toThrow();
});
it('hexToBytes / bytesToHex roundtrip', () => {
for (const v of staticHexVectors) {
expect(hexToBytes(v.hex)).toEqual(v.bytes);
expect(hexToBytes(v.hex.toUpperCase())).toEqual(v.bytes);
expect(bytesToHex(v.bytes)).toEqual(v.hex);
// encode -> decode
const h = bytesToHex(v.bytes);
expect(hexToBytes(h)).toEqual(v.bytes);
}
});
it('base64 encodings roundtrip and variants', () => {
const hello = new Uint8Array([72,101,108,108,111]); // 'Hello'
const b64 = bytesToBase64(hello, 'base64');
expect(base64ToBytes(b64)).toEqual(hello);
const b64raw = bytesToBase64(hello, 'base64raw');
expect(base64ToBytes(b64raw)).toEqual(hello);
const b64url = bytesToBase64(hello, 'base64url');
expect(base64ToBytes(b64url)).toEqual(hello);
const b64urlraw = bytesToBase64(hello, 'base64urlraw');
expect(base64ToBytes(b64urlraw)).toEqual(hello);
});
it('encodeBytes / decodeBytes round robin across encodings', () => {
const bytes = Uint8Array.from([0xde,0xad,0xbe,0xef]);
const encodings = ['hex','base64','base64raw','base64url','base64urlraw'] as const;
for (const enc of encodings) {
const s = encodeBytes(bytes, enc as any);
const out = decodeBytes(s, enc as any);
expect(out).toEqual(bytes);
}
});
it('throws on invalid inputs for hex/base64 decoders', () => {
expect(() => hexToBytes('z')).toThrow();
expect(() => base64ToBytes('??')).toThrow();
expect(() => decodeBytes('abc', 'unsupported' as any)).toThrow();
});
it('edge cases: offsets, odd lengths, url base64, empty and large roundtrips', () => {
// U16/U32 with subarray offset
const buf = new ArrayBuffer(6);
const v = new Uint8Array(buf);
v.set([1,2,3,4,5,6]);
const view = new Uint8Array(buf, 1, 4); // [2,3,4,5]
const u8 = U8(view);
expect(Array.from(u8)).toEqual([2,3,4,5]);
// Unaligned views cannot be reinterpreted as 16-bit/32-bit arrays on some
// platforms; U16/U32 will throw for misaligned offsets. Assert that behavior.
expect(() => U16(view)).toThrow();
// hexToBytes with odd length should throw
expect(() => hexToBytes('f')).toThrow();
// base64 url-safe without padding should be accepted
const sample = new Uint8Array([0x01,0x02,0x03,0xff]);
const raw = bytesToBase64(sample, 'base64raw');
// url variant
const url = bytesToBase64(sample, 'base64urlraw');
expect(base64ToBytes(raw)).toEqual(sample);
expect(base64ToBytes(url)).toEqual(sample);
// empty inputs
expect(bytesToHex(new Uint8Array([]))).toBe('');
expect(bytesToBase64(new Uint8Array([]), 'base64')).toBe('');
// large random roundtrip
const large = new Uint8Array(10000);
for (let i = 0; i < large.length; i++) large[i] = i & 0xff;
const h = bytesToHex(large);
expect(hexToBytes(h)).toEqual(large);
const b64 = bytesToBase64(large, 'base64raw');
expect(base64ToBytes(b64)).toEqual(large);
});
});

163
src/utils.ts Normal file
View File

@@ -0,0 +1,163 @@
export type TypedArray = Int8Array | Uint8ClampedArray | Uint8Array | Uint16Array | Int16Array | Uint32Array | Int32Array;
export const U8 = (arr: ArrayBuffer | TypedArray): Uint8Array => {
if (arr instanceof ArrayBuffer) return new Uint8Array(arr);
return new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
}
export const I8 = (arr: ArrayBuffer | TypedArray): Int8Array => {
if (arr instanceof ArrayBuffer) return new Int8Array(arr);
return new Int8Array(arr.buffer, arr.byteOffset, arr.byteLength);
}
export const U16 = (arr: ArrayBuffer | TypedArray): Uint16Array => {
if (arr instanceof ArrayBuffer) return new Uint16Array(arr);
return new Uint16Array(arr.buffer, arr.byteOffset, Math.floor(arr.byteLength / 2));
}
export const I16 = (arr: ArrayBuffer | TypedArray): Int16Array => {
if (arr instanceof ArrayBuffer) return new Int16Array(arr);
return new Int16Array(arr.buffer, arr.byteOffset, Math.floor(arr.byteLength / 2));
}
export const U32 = (arr: ArrayBuffer | TypedArray): Uint32Array => {
if (arr instanceof ArrayBuffer) return new Uint32Array(arr);
return new Uint32Array(arr.buffer, arr.byteOffset, Math.floor(arr.byteLength / 4));
}
export const I32 = (arr: ArrayBuffer | TypedArray): Int32Array => {
if (arr instanceof ArrayBuffer) return new Int32Array(arr);
return new Int32Array(arr.buffer, arr.byteOffset, Math.floor(arr.byteLength / 4));
}
export const F32 = (arr: ArrayBuffer | TypedArray): Float32Array => {
if (arr instanceof ArrayBuffer) return new Float32Array(arr);
return new Float32Array(arr.buffer, arr.byteOffset, Math.floor(arr.byteLength / 4));
}
export const F64 = (arr: ArrayBuffer | TypedArray): Float64Array => {
if (arr instanceof ArrayBuffer) return new Float64Array(arr);
return new Float64Array(arr.buffer, arr.byteOffset, Math.floor(arr.byteLength / 8));
}
export const isBytes = (bytes: unknown): boolean => {
return bytes instanceof Uint8Array || (ArrayBuffer.isView(bytes) && bytes.constructor.name === "Uint8Array");
}
export const assertBytes = (bytes: Uint8Array, length?: number, name: string = ""): void => {
const valid = isBytes(bytes);
const sized = (typeof length !== 'undefined') ? (bytes.byteLength === length) : true;
if (!valid || !sized) {
const expected = typeof length !== 'undefined' ? `Uint8Array of length ${length}` : 'Uint8Array';
const actual = valid ? `Uint8Array of length ${bytes.byteLength}` : typeof bytes;
throw new TypeError(`Expected ${name} to be ${expected}, got ${actual}`);
}
}
const hasHexMethods = (() =>
// @ts-ignore testing for builtins
'toHex' in Uint8Array.from([]) && typeof Uint8Array.from([])['toHex'] === 'function' &&
'fromHex' in Uint8Array && typeof Uint8Array.fromHex === 'function')();
// Array where index 0xf0 (240) is mapped to string 'f0'
const hexes = Array.from({ length: 256 }, (_, i) =>
i.toString(16).padStart(2, '0')
);
export type HexEncoding = 'hex';
export type Base64Encoding = 'base64';
export type Base64RawEncoding = 'base64raw';
export type Base64URLEncoding = 'base64url';
export type Base64RawURLEncoding = 'base64urlraw';
export type Encoding = HexEncoding | Base64Encoding | Base64RawEncoding | Base64URLEncoding | Base64RawURLEncoding;
export const bytesToHex = (bytes: Uint8Array, length?: number): string => {
assertBytes(bytes, (typeof length !== 'undefined') ? length * 2 : undefined, "bytes");
let hex = '';
for (const byte of bytes) {
hex += hexes[byte];
}
return hex;
};
export const hexToBytes = (hex: string): Uint8Array => {
if (typeof hex !== 'string' || hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) {
throw new TypeError(`Expected hex string of even length, got ${hex}`);
}
if (hasHexMethods) {
// @ts-ignore using built-in hex methods if available
return Uint8Array.fromHex(hex);
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
}
return bytes;
}
export const bytesToBase64 = (bytes: Uint8Array, encoding: Base64Encoding | Base64RawEncoding | Base64URLEncoding | Base64RawURLEncoding): string => {
assertBytes(bytes, (typeof length !== 'undefined') ? length * 2 : undefined, "bytes");
const binary = String.fromCharCode(...bytes);
switch (encoding) {
case 'base64':
return btoa(binary);
case 'base64raw':
return btoa(binary).replace(/=+$/, '');
case 'base64url':
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_');
case 'base64urlraw':
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
default:
throw new TypeError(`Unsupported encoding: ${encoding}`);
}
}
export const base64ToBytes = (b64: string): Uint8Array => {
if (typeof b64 !== "string") {
throw new TypeError(`Expected base64 string, got ${b64}`);
}
// Accept URL-safe base64 by replacing '-' with '+' and '_' with '/'
let normalized = b64.replace(/-/g, "+").replace(/_/g, "/");
// Pad with '=' to make length a multiple of 4
if (normalized.length % 4 !== 0) {
normalized += "=".repeat(4 - (normalized.length % 4));
}
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
throw new TypeError(`Expected base64 string, got ${b64}`);
}
const binary = atob(normalized);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
};
export const decodeBytes = (encoded: string, encoding: Encoding): Uint8Array => {
switch (encoding) {
case 'hex':
return hexToBytes(encoded);
case 'base64':
case 'base64raw':
case 'base64url':
case 'base64urlraw':
return base64ToBytes(encoded);
default:
throw new TypeError(`Unsupported encoding: ${encoding}`);
}
}
export const encodeBytes = (bytes: Uint8Array, encoding: Encoding): string => {
switch (encoding) {
case 'hex':
return bytesToHex(bytes);
case 'base64':
case 'base64raw':
case 'base64url':
case 'base64urlraw':
return bytesToBase64(bytes, encoding);
default:
throw new TypeError(`Unsupported encoding: ${encoding}`);
}
}