16 Commits

Author SHA1 Message Date
71ae225972 Version 1.1.1 2026-03-18 17:33:03 +01:00
3855e478c0 Allow Field to specify its own data 2026-03-18 17:32:53 +01:00
93a642f242 Version 1.1.0 2026-03-15 15:46:25 +01:00
8a641cbc02 Added char type and Segment.isString 2026-03-15 15:46:03 +01:00
b3d5aace89 Use eslint.config.ts 2026-03-12 21:11:26 +01:00
8994fb7b45 Version 1.0.5 2026-03-12 21:06:18 +01:00
a5acb5ed03 Version 1.0.4 2026-03-12 21:02:16 +01:00
2a5e4b1052 Export all the things 2026-03-12 19:52:27 +01:00
c18a544a2e Version 1.0.3 2026-03-12 18:21:34 +01:00
25c07c947a Expose equalBytes and constantTimeEqualBytes 2026-03-12 18:21:21 +01:00
4170305233 Version 1.0.2 2026-03-12 18:16:09 +01:00
a83c4ca4c0 Added docstrings and functions for testing Uint8Array equality 2026-03-12 18:15:56 +01:00
01978cb867 Version 1.0.1 2026-03-12 18:02:32 +01:00
1fe9bb00d4 Ignore package-lock.json 2026-03-12 18:02:15 +01:00
3de4ec28b4 Added utils package for parsing helpers 2026-03-12 18:00:52 +01:00
c33f6f781c Added license 2026-03-12 17:11:18 +01:00
13 changed files with 862 additions and 4093 deletions

3
.gitignore vendored
View File

@@ -103,6 +103,9 @@ web_modules/
# Optional npm cache directory
.npm
# Optional npm package-lock.json
package-lock.json
# Optional eslint cache
.eslintcache

View File

@@ -11,16 +11,22 @@ repos:
hooks:
- id: shellcheck
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v10.0.3
- repo: local
hooks:
- id: prettier
name: prettier
entry: npx prettier --write
language: system
files: "\\.(js|jsx|ts|tsx)$"
- repo: local
hooks:
- id: eslint
name: eslint
entry: npx eslint --fix
language: system
files: "\\.(js|jsx|ts|tsx)$"
exclude: node_modules/
# Use stylelint (local) instead of the deprecated scss-lint Ruby gem which
# cannot parse modern Sass `@use` and module syntax. This invokes the
# project's installed `stylelint` via `npx` so the devDependency is used.
- repo: local
hooks:
- id: stylelint

8
.prettierrc.ts Normal file
View File

@@ -0,0 +1,8 @@
import { type Config } from "prettier";
const config: Config = {
trailingComma: "none",
printWidth: 120
};
export default config;

25
LICENSE.md Normal file
View File

@@ -0,0 +1,25 @@
The MIT License (MIT)
=====================
Copyright © `2026` `Wijnand Modderman-Lenstra <maze@maze.io>`
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the “Software”), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,19 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

24
eslint.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [js.configs.recommended, tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser
},
rules: {
"@typescript-eslint/ban-ts-comment": [
"error",
{
"ts-ignore": "allow-with-description"
}
]
}
}
]);

3957
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
{
"name": "@hamradio/packet",
"version": "1.0.0",
"type": "module",
"version": "1.1.1",
"description": "Low level packet parsing library (for radio protocols)",
"keywords": [
"HAM radio",
@@ -12,12 +13,12 @@
],
"repository": {
"type": "git",
"url": "https://git.maze.io/ham/packet.js"
"url": "https://git.maze.io/ham/packet.ts"
},
"license": "MIT",
"author": "Wijnand Modderman-Lenstra",
"main": "dist/index.js",
"module": "dist/index.mjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
@@ -25,7 +26,7 @@
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"import": "./dist/index.js",
"require": "./dist/index.js"
}
},
@@ -39,12 +40,13 @@
"lint": "eslint .",
"prepare": "npm run build"
},
"dependencies": {},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^10.0.3",
"globals": "^17.4.0",
"jiti": "^2.6.1",
"prettier": "3.8.1",
"tsup": "^8.5.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.0",

View File

@@ -1,28 +1,30 @@
import { describe, it, expect } from 'vitest';
import { Reader, Writer } from '.';
import { describe, it, expect } from "vitest";
import { Reader, Writer } from ".";
describe('Reader/Writer round-trip', () => {
it('writes then reads a variety of types correctly', () => {
describe("Reader/Writer round-trip", () => {
it("writes then reads a variety of types correctly", () => {
const writer = new Writer(1024);
// values to write
const bBool = true;
const bChar = "A";
const bInt8 = -42;
const bUint8 = 200;
const bUint16 = 0xBEEF;
const bUint32 = 0xDEADBEEF >>> 0;
const bUint16 = 0xbeef;
const bUint32 = 0xdeadbeef >>> 0;
const bUint64 = 0x1122334455667788n;
const bFloat32 = 3.1415927;
const bFloat64 = 1.23456789e5;
const bCString = 'hello';
const bUtf8 = 'π≈3.14';
const words = [0x1234, 0x5678, 0x9ABC];
const bCString = "hello";
const bUtf8 = "π≈3.14";
const words = [0x1234, 0x5678, 0x9abc];
const dwords = [0x11223344 >>> 0, 0x55667788 >>> 0];
const qwords = [0x8000000000000000n, 0xAABBCCDDEEFF0011n];
const qwords = [0x8000000000000000n, 0xaabbccddeeff0011n];
const rawBytes = new Uint8Array([1, 2, 3, 4]);
// write sequence
writer.bool(bBool);
writer.char(bChar);
writer.int8(bInt8);
writer.uint8(bUint8);
writer.uint16(bUint16);
@@ -45,6 +47,7 @@ describe('Reader/Writer round-trip', () => {
// read back in same order
expect(reader.bool()).toBe(bBool);
expect(reader.char()).toBe(bChar);
expect(reader.int8()).toBe(bInt8);
expect(reader.uint8()).toBe(bUint8);
expect(reader.uint16()).toBe(bUint16);

View File

@@ -15,7 +15,7 @@ export class Reader {
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.');
throw new TypeError("Invalid buffer type. Expected ArrayBuffer, Uint8Array, or ArrayBufferView.");
}
this.view = new DataView(this.buffer);
this.offset = 0;
@@ -35,6 +35,17 @@ export class Reader {
return value !== 0;
}
/**
* Read an 8-bit character from the buffer at the current offset, and advance the offset by 1 byte.
* @returns A string containing the character read from the buffer.
*/
public char(): string {
this.checkBounds(1);
const value = this.view.getUint8(this.offset);
this.offset += 1;
return String.fromCharCode(value);
}
/**
* Read an 8-bit signed integer from the buffer at the current offset, and advance the offset by 1 byte.
*
@@ -179,7 +190,7 @@ export class Reader {
while (true) {
this.checkBounds(1);
const byte = this.view.getUint8(this.offset++);
result |= (byte & 0x7F) << shift;
result |= (byte & 0x7f) << shift;
if ((byte & 0x80) === 0) {
break; // Last byte of the varint
}
@@ -205,7 +216,7 @@ export class Reader {
while (true) {
this.checkBounds(1);
const byte = this.view.getUint8(this.offset++);
result |= (byte & 0x7F) << shift;
result |= (byte & 0x7f) << shift;
if ((byte & 0x80) === 0) {
break; // Last byte of the varint
}
@@ -342,32 +353,36 @@ export class Reader {
return new Reader(bytes, littleEndian);
}
public static fromString(str: string, encoding: 'utf8' | 'ascii' | 'hex' | 'base64' | 'rawbase64' | 'urlbase64' | 'rawurlbase64' = 'utf8', littleEndian: boolean = true): Reader {
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':
case "utf8":
bytes = new TextEncoder().encode(str);
break;
case 'ascii':
bytes = new Uint8Array(str.split('').map(c => c.charCodeAt(0)));
case "ascii":
bytes = new Uint8Array(str.split("").map((c) => c.charCodeAt(0)));
break;
case 'hex':
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));
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));
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));
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));
case "rawurlbase64":
bytes = Uint8Array.from(atob(str.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
break;
}
return new Reader(bytes.slice().buffer, littleEndian);
@@ -387,7 +402,9 @@ export class Reader {
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}`);
throw new RangeError(
`Attempt to read beyond end of buffer: offset=${this.offset}, length=${length}, bufferLength=${this.view.byteLength}`
);
}
}
}
@@ -428,6 +445,27 @@ export class Writer {
this.offset += 1;
}
/**
* Write an 8-bit character to the buffer at the current offset, and advance the offset by 1 byte.
*
* @param value The character or its ASCII code to write.
*/
public char(value: string | number): void {
if (typeof value === "string") {
if (value.length !== 1) {
throw new Error("Input must be a single character");
}
this.checkBounds(1);
this.view.setUint8(this.offset, value.charCodeAt(0));
} else if (typeof value === "number") {
this.checkBounds(1);
this.view.setUint8(this.offset, value);
} else {
throw new Error("Input must be a string or number");
}
this.offset += 1;
}
/**
* Write an 8-bit signed integer to the buffer at the current offset, and advance the offset by
* 1 byte.
@@ -472,7 +510,7 @@ export class Writer {
*/
public int64(value: number | bigint): void {
this.checkBounds(8);
if (typeof value === 'number') {
if (typeof value === "number") {
value = BigInt(value);
}
this.view.setBigInt64(this.offset, value, this.littleEndian);
@@ -523,7 +561,7 @@ export class Writer {
*/
public uint64(value: number | bigint): void {
this.checkBounds(8);
if (typeof value === 'number') {
if (typeof value === "number") {
value = BigInt(value);
}
this.view.setBigUint64(this.offset, value, this.littleEndian);
@@ -570,7 +608,7 @@ export class Writer {
// Useful for pre-sizing buffers.
let remaining = value >>> 0; // Ensure unsigned
while (remaining >= 0x80) {
this.view.setUint8(this.offset++, (remaining & 0x7F) | 0x80);
this.view.setUint8(this.offset++, (remaining & 0x7f) | 0x80);
remaining >>>= 7;
}
this.view.setUint8(this.offset++, remaining);
@@ -591,7 +629,7 @@ export class Writer {
let remaining = value >>> 0; // Ensure unsigned
const isNegative = value < 0;
while (remaining >= 0x80 || (isNegative && remaining < 0x80)) {
this.view.setUint8(this.offset++, (remaining & 0x7F) | 0x80);
this.view.setUint8(this.offset++, (remaining & 0x7f) | 0x80);
remaining >>>= 7;
}
this.view.setUint8(this.offset++, remaining);
@@ -701,46 +739,60 @@ export class Writer {
* @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 {
public toString(encoding: "utf-8" | "hex" | "base64" | "rawbase64" | "urlbase64" | "rawurlbase64" = "utf-8"): string {
const bytes = this.toBytes();
switch (encoding) {
case 'utf-8':
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':
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(/=+$/, '');
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 {
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':
case "utf-8":
bytes = new TextEncoder().encode(value);
break;
case 'hex':
bytes = new Uint8Array(value.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
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));
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));
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));
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));
case "rawurlbase64":
bytes = Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
break;
default:
throw new Error(`Unsupported encoding: ${encoding}`);
@@ -764,9 +816,48 @@ export class Writer {
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}`);
throw new RangeError(
`Attempt to write beyond end of buffer: offset=${this.offset}, length=${length}, bufferLength=${this.view.byteLength}`
);
}
}
}
export { FieldType } from './types';
// Exporting types and utilities for external use:
export * from "./types";
export {
FieldType,
type Packet,
type Protocol,
type Dissected,
type Segment,
type Field,
type BitField
} from "./types";
export {
I8,
I16,
I32,
U8,
U16,
U32,
F32,
F64,
isBytes,
assertBytes,
equalBytes,
constantTimeEqualBytes,
base64ToBytes,
bytesToBase64,
hexToBytes,
bytesToHex,
decodeBytes,
encodeBytes,
type TypedArray,
type HexEncoding,
type Base64Encoding,
type Base64RawEncoding,
type Base64URLEncoding,
type Base64RawURLEncoding,
type Encoding
} from "./utils";

View File

@@ -1,9 +1,7 @@
/**
* Type definitions for the packet parsing library.
*/
/**
* Enumeration of supported field types for dissecting packets.
*
@@ -13,62 +11,63 @@
*/
export const FieldType = {
// Boolean types
BOOL: 'BOOL', // Boolean value (stored as a byte, 0 for false, non-zero for true)
BOOL: "BOOL", // Boolean value (stored as a byte, 0 for false, non-zero for true)
// Number types
BITS: 'BITS', // 1-bit values stored in a number (1 bit per value, packed into bytes)
INT8: 'INT8', // 8-bit signed integer (1 byte)
INT16_LE: 'INT16_LE', // 16-bit signed integer (little-endian)
INT16_BE: 'INT16_BE', // 16-bit signed integer (big-endian)
INT32_LE: 'INT32_LE', // 32-bit signed integer (little-endian)
INT32_BE: 'INT32_BE', // 32-bit signed integer (big-endian)
INT64_LE: 'INT64_LE', // 64-bit signed integer (little-endian)
INT64_BE: 'INT64_BE', // 64-bit signed integer (big-endian)
UINT8: 'UINT8', // 8-bit unsigned integer
UINT16_LE: 'UINT16_LE', // 16-bit unsigned integer (little-endian)
UINT16_BE: 'UINT16_BE', // 16-bit unsigned integer (big-endian)
UINT32_LE: 'UINT32_LE', // 32-bit unsigned integer (little-endian)
UINT32_BE: 'UINT32_BE', // 32-bit unsigned integer (big-endian)
UINT64_LE: 'UINT64_LE', // 64-bit unsigned integer (little-endian)
UINT64_BE: 'UINT64_BE', // 64-bit unsigned integer (big-endian)
FLOAT32_LE: 'FLOAT32_LE', // 32-bit IEEE floating point (little-endian)
FLOAT32_BE: 'FLOAT32_BE', // 32-bit IEEE floating point (big-endian)
FLOAT64_LE: 'FLOAT64_LE', // 64-bit IEEE floating point (little-endian)
FLOAT64_BE: 'FLOAT64_BE', // 64-bit IEEE floating point (big-endian)
VARINT: 'VARINT', // Variable-length integer (unsigned, LEB128 encoding)
VARSINT: 'VARSINT', // Variable-length integer (signed, LEB128 encoding)
BITS: "BITS", // 1-bit values stored in a number (1 bit per value, packed into bytes)
CHAR: "CHAR", // 8-bit character (1 byte)
INT8: "INT8", // 8-bit signed integer (1 byte)
INT16_LE: "INT16_LE", // 16-bit signed integer (little-endian)
INT16_BE: "INT16_BE", // 16-bit signed integer (big-endian)
INT32_LE: "INT32_LE", // 32-bit signed integer (little-endian)
INT32_BE: "INT32_BE", // 32-bit signed integer (big-endian)
INT64_LE: "INT64_LE", // 64-bit signed integer (little-endian)
INT64_BE: "INT64_BE", // 64-bit signed integer (big-endian)
UINT8: "UINT8", // 8-bit unsigned integer
UINT16_LE: "UINT16_LE", // 16-bit unsigned integer (little-endian)
UINT16_BE: "UINT16_BE", // 16-bit unsigned integer (big-endian)
UINT32_LE: "UINT32_LE", // 32-bit unsigned integer (little-endian)
UINT32_BE: "UINT32_BE", // 32-bit unsigned integer (big-endian)
UINT64_LE: "UINT64_LE", // 64-bit unsigned integer (little-endian)
UINT64_BE: "UINT64_BE", // 64-bit unsigned integer (big-endian)
FLOAT32_LE: "FLOAT32_LE", // 32-bit IEEE floating point (little-endian)
FLOAT32_BE: "FLOAT32_BE", // 32-bit IEEE floating point (big-endian)
FLOAT64_LE: "FLOAT64_LE", // 64-bit IEEE floating point (little-endian)
FLOAT64_BE: "FLOAT64_BE", // 64-bit IEEE floating point (big-endian)
VARINT: "VARINT", // Variable-length integer (unsigned, LEB128 encoding)
VARSINT: "VARSINT", // Variable-length integer (signed, LEB128 encoding)
// Date/time types (stored as integer timestamps)
DATE32_LE: 'DATE32_LE', // 32-bit integer date (e.g., seconds since epoch) little-endian
DATE32_BE: 'DATE32_BE', // 32-bit integer date big-endian
DATE64_LE: 'DATE64_LE', // 64-bit integer date (e.g., ms since epoch) little-endian
DATE64_BE: 'DATE64_BE', // 64-bit integer date big-endian
DATE32_LE: "DATE32_LE", // 32-bit integer date (e.g., seconds since epoch) little-endian
DATE32_BE: "DATE32_BE", // 32-bit integer date big-endian
DATE64_LE: "DATE64_LE", // 64-bit integer date (e.g., ms since epoch) little-endian
DATE64_BE: "DATE64_BE", // 64-bit integer date big-endian
// Array buffer types
BYTES: 'BYTES', // 8-bits per value array (Uint8Array)
C_STRING: 'C_STRING', // Null-terminated string (C-style) (Uint8Array)
UTF8_STRING: 'UTF8_STRING', // UTF-8 encoded string (Uint8Array)
WORDS: 'WORDS', // 16-bits per value array (Uint16Array)
DWORDS: 'DWORDS', // 32-bits per value array (Uint32Array)
QWORDS: 'QWORDS', // 64-bits per value array (BigUint64Array)
BYTES: "BYTES", // 8-bits per value array (Uint8Array)
C_STRING: "C_STRING", // Null-terminated string (C-style) (Uint8Array)
UTF8_STRING: "UTF8_STRING", // UTF-8 encoded string (Uint8Array)
WORDS: "WORDS", // 16-bits per value array (Uint16Array)
DWORDS: "DWORDS", // 32-bits per value array (Uint32Array)
QWORDS: "QWORDS", // 64-bits per value array (BigUint64Array)
// Aliases
FLAG: 'BOOL', // alternate name for boolean/flag fields
STRING: 'UTF8_STRING', // alias for UTF8 encoded strings
FLAG: "BOOL", // alternate name for boolean/flag fields
STRING: "UTF8_STRING" // alias for UTF8 encoded strings
} as const;
export type FieldType = typeof FieldType[keyof typeof FieldType];
export type FieldType = (typeof FieldType)[keyof typeof FieldType];
/**
* Interface for a packet, which can be dissected into segments and fields. This is a placeholder
* for any additional properties or methods that may be needed for representing a packet in the future.
*/
export interface Packet {
data: string | Uint8Array | ArrayBuffer; // Raw packet data as an ArrayBuffer
snr?: number; // Optional signal-to-noise ratio (for radio packets)
rssi?: number; // Optional received signal strength indicator (for radio packets)
parsed?: unknown; // Optional parsed representation of the packet (e.g., a structured object)
dissected?: Dissected; // Optional dissected representation of the packet (array of segments)
data: string | Uint8Array | ArrayBuffer; // Raw packet data as an ArrayBuffer
snr?: number; // Optional signal-to-noise ratio (for radio packets)
rssi?: number; // Optional received signal strength indicator (for radio packets)
parsed?: unknown; // Optional parsed representation of the packet (e.g., a structured object)
dissected?: Dissected; // Optional dissected representation of the packet (array of segments)
/**
* Method to dissect the packet into segments and fields, returning an array of segments.
@@ -108,8 +107,9 @@ export type Dissected = Segment[];
* Each field specifies the type and length of data it represents.
*/
export interface Segment {
name: string;
data?: ArrayBuffer; // Optional raw data for the segment (if needed for parsing / serialization)
name: string;
data?: ArrayBuffer; // Optional raw data for the segment (if needed for parsing / serialization)
isString?: boolean; // Optional flag indicating if the segment represents a string (for special handling)
fields: Field[];
}
@@ -118,11 +118,12 @@ export interface Segment {
* for bit fields and array lengths.
*/
export interface Field {
type: FieldType;
name: string;
value?: unknown; // Optional value for the field (used for serialization or as a default value)
bits?: BitField[]; // Optional array of bit field definitions (for BITS type)
length?: number; // Optional length for array types (e.g., BYTES, WORDS)
type: FieldType;
name: string;
data?: ArrayBuffer; // Optional raw data for the field (if needed for parsing / serialization)
value?: unknown; // Optional value for the field (used for serialization or as a default value)
bits?: BitField[]; // Optional array of bit field definitions (for BITS type)
length?: number; // Optional length for array types (e.g., BYTES, WORDS)
}
/**
@@ -131,6 +132,6 @@ export interface Field {
*/
export interface BitField {
name: string;
size: number; // Number of bits for this field (must be > 0)
size: number; // Number of bits for this field (must be > 0)
lsb?: boolean; // Optional flag indicating if this field is the least significant bit in the byte (for BITS type)
}

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);
});
});

358
src/utils.ts Normal file
View File

@@ -0,0 +1,358 @@
// TypedArray is a union type of all the standard typed array types in JavaScript. This allows us to write
// functions that can accept any typed array or an ArrayBuffer and convert it to the appropriate view.
export type TypedArray = Int8Array | Uint8ClampedArray | Uint8Array | Uint16Array | Int16Array | Uint32Array | Int32Array;
/**
* Converts the given ArrayBuffer or TypedArray to a Uint8Array.
*
* @param arr The ArrayBuffer or TypedArray to convert.
* @returns A Uint8Array representing the same data.
*/
export const U8 = (arr: ArrayBuffer | TypedArray): Uint8Array => {
if (arr instanceof ArrayBuffer) return new Uint8Array(arr);
return new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength);
}
/**
* Converts the given ArrayBuffer or TypedArray to an Int8Array.
*
* @param arr The ArrayBuffer or TypedArray to convert.
* @returns An Int8Array representing the same data.
*/
export const I8 = (arr: ArrayBuffer | TypedArray): Int8Array => {
if (arr instanceof ArrayBuffer) return new Int8Array(arr);
return new Int8Array(arr.buffer, arr.byteOffset, arr.byteLength);
}
/**
* Converts the given ArrayBuffer or TypedArray to a Uint16Array. If a TypedArray is provided,
* the byte length is adjusted to account for the 2 bytes per value in a Uint16Array.
*
* @param arr The ArrayBuffer or TypedArray to convert.
* @returns A Uint16Array representing the same data.
*/
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));
}
/**
* Converts the given ArrayBuffer or TypedArray to an Int16Array. If a TypedArray is provided,
* the byte length is adjusted to account for the 2 bytes per value in an Int16Array.
*
* @param arr The ArrayBuffer or TypedArray to convert.
* @returns An Int16Array representing the same data.
*/
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));
}
/**
* Converts the given ArrayBuffer or TypedArray to a Uint32Array. If a TypedArray is provided,
* the byte length is adjusted to account for the 4 bytes per value in a Uint32Array.
*
* @param arr The ArrayBuffer or TypedArray to convert.
* @returns A Uint32Array representing the same data.
*/
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));
}
/**
* Converts the given ArrayBuffer or TypedArray to an Int32Array. If a TypedArray is provided,
* the byte length is adjusted to account for the 4 bytes per value in an Int32Array.
*
* @param arr The ArrayBuffer or TypedArray to convert.
* @returns An Int32Array representing the same data.
*/
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));
}
/**
* Converts the given ArrayBuffer or TypedArray to a Float32Array. If a TypedArray is provided,
* the byte length is adjusted to account for the 4 bytes per value in a Float32Array.
*
* @param arr The ArrayBuffer or TypedArray to convert.
* @returns A Float32Array representing the same data.
*/
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));
}
/**
* Converts the given ArrayBuffer or TypedArray to a Float64Array. If a TypedArray is provided,
* the byte length is adjusted to account for the 8 bytes per value in a Float64Array.
*
* @param arr The ArrayBuffer or TypedArray to convert.
* @returns A Float64Array representing the same data.
*/
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));
}
/**
* Tests whether the provided value is a Uint8Array or a view of an ArrayBuffer that is a
* Uint8Array. This is used to validate that a given value can be treated as raw bytes for
* encoding/decoding operations.
*
* @param bytes The value to test.
* @returns True if the value is a Uint8Array or a view of an ArrayBuffer that is a Uint8Array, false otherwise.
*/
export const isBytes = (bytes: unknown): boolean => {
return bytes instanceof Uint8Array || (ArrayBuffer.isView(bytes) && bytes.constructor.name === "Uint8Array");
}
/**
* Asserts that the provided value is a Uint8Array of the specified length (if provided). Throws a
* TypeError if the assertion fails.
*
* @param bytes The value to check.
* @param length The expected length of the Uint8Array.
* @param name The name of the variable being checked (for error messages).
*/
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}`);
}
}
/**
* Test for equality of two Uint8Arrays. Returns true if the arrays are equal, false otherwise.
*
* Note: This function is not designed to be resistant to timing attacks. For security-sensitive
* comparisons (e.g., cryptographic keys, hashes), use `constantTimeEqualBytes` instead.
*
* @param a The first Uint8Array to compare.
* @param b The second Uint8Array to compare.
* @returns True if the arrays are equal, false otherwise.
*/
export const equalBytes = (a: Uint8Array, b: Uint8Array): boolean => {
if (a.byteLength !== b.byteLength) return false;
for (let i = 0; i < a.byteLength; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
/**
* Performs a constant-time comparison of two Uint8Arrays to prevent timing attacks. Returns true
* if the arrays are equal, false otherwise.
*
* This function should be used for security-sensitive comparisons (e.g., cryptographic keys,
* hashes) to mitigate timing attacks. For general use where performance is a concern and security
* is not an issue, `equalBytes` may be more efficient.
*
* NB: This function is not truly constant-time in JavaScript due to the nature of the language and
* runtime, but it is designed to minimize timing differences based on the content of the arrays.
*
* @param a The first Uint8Array to compare.
* @param b The second Uint8Array to compare.
* @returns True if the arrays are equal, false otherwise.
*/
export const constantTimeEqualBytes = (a: Uint8Array, b: Uint8Array): boolean => {
if (a.byteLength !== b.byteLength) return false;
let result = 0;
for (let i = 0; i < a.byteLength; i++) {
result |= a[i] ^ b[i];
}
return result === 0;
}
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;
/**
* Converts a Uint8Array of bytes to a hexadecimal string. If the environment supports built-in
* hex methods on Uint8Array, those will be used for better performance. Otherwise, a manual
* conversion is performed.
*
* If a length is provided, the function will assert that the input bytes have the expected
* length (in bytes). The length parameter is optional and can be used to enforce that the
* input bytes match an expected size for encoding/decoding operations.
*
* @param bytes The Uint8Array of bytes to convert to a hexadecimal string.
* @param length Optional expected length of the input bytes (in bytes).
* @returns The hexadecimal string representation of the input bytes.
*/
export const bytesToHex = (bytes: Uint8Array, length?: number): string => {
assertBytes(bytes, (typeof length !== 'undefined') ? length * 2 : undefined, "bytes");
if (hasHexMethods) {
// @ts-ignore using built-in hex methods if available
return bytes.toHex();
}
let hex = '';
for (const byte of bytes) {
hex += hexes[byte];
}
return hex;
};
/**
* Converts a hexadecimal string to a Uint8Array of bytes. If the environment supports built-in
* hex methods on Uint8Array, those will be used for better performance. Otherwise, a manual
* conversion is performed.
*
* @param hex The hexadecimal string to convert to a Uint8Array of bytes.
* @returns The Uint8Array of bytes represented by the hexadecimal string.
*/
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;
}
/**
* Converts a Uint8Array of bytes to a base64 string using the specified encoding variant. The function
* asserts that the input is a valid Uint8Array and optionally checks for an expected length (in bytes).
*
* The `encoding` parameter specifies the base64 variant to use:
* - 'base64': Standard base64 encoding with padding.
* - 'base64raw': Standard base64 encoding without padding.
* - 'base64url': URL-safe base64 encoding with padding (replaces '+' with '-' and '/' with '_').
* - 'base64urlraw': URL-safe base64 encoding without padding.
*
* @param bytes The Uint8Array of bytes to convert to a base64 string.
* @param encoding The base64 encoding variant to use.
* @returns The base64 string representation of the input 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}`);
}
}
/**
* Converts a base64 string to a Uint8Array of bytes. The function accepts both standard and URL-safe
* base64 variants, and normalizes the input by replacing URL-safe characters and adding padding if necessary.
*
* The function asserts that the input is a valid base64 string after normalization. It then decodes the
* base64 string to binary and converts it to a Uint8Array of bytes.
*
* @param b64 The base64 string to convert to a Uint8Array of bytes.
* @returns The Uint8Array of bytes represented by the base64 string.
*/
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;
};
/**
* Decodes a string encoded in the specified encoding variant to a Uint8Array of bytes. The function
* supports the following encoding variants:
* - 'hex': Hexadecimal encoding (e.g., "deadbeef").
* - 'base64': Standard base64 encoding with padding.
* - 'base64raw': Standard base64 encoding without padding.
* - 'base64url': URL-safe base64 encoding with padding (replaces '+' with '-' and '/' with '_').
* - 'base64urlraw': URL-safe base64 encoding without padding.
*
* The function asserts that the input string is valid for the specified encoding and then decodes
* it to a Uint8Array of bytes. If the encoding is not supported, a TypeError is thrown.
*
* @param encoded The encoded string to decode.
* @param encoding The encoding variant of the input string.
* @returns The Uint8Array of bytes represented by the encoded string.
*/
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}`);
}
}
/**
* Encodes a Uint8Array of bytes to a string using the specified encoding variant. The function supports
* the following encoding variants:
* - 'hex': Hexadecimal encoding (e.g., "deadbeef").
* - 'base64': Standard base64 encoding with padding.
* - 'base64raw': Standard base64 encoding without padding.
* - 'base64url': URL-safe base64 encoding with padding (replaces '+' with '-' and '/' with '_').
* - 'base64urlraw': URL-safe base64 encoding without padding.
*
* The function asserts that the input is a valid Uint8Array of bytes and then encodes it to a string
* using the specified encoding variant. If the encoding is not supported, a TypeError is thrown.
*
* @param bytes The Uint8Array of bytes to encode.
* @param encoding The encoding variant to use.
* @returns The encoded string.
*/
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}`);
}
}