Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
# @suaveplan/bytes - Low-level byte utilities: hex/base64/utf8 encoding, Uint8Array helpers, UUID conversion
## Summary
Low-level byte utilities for Suaveplan: lossless hex/base64/UTF-8 encoding, `Uint8Array` manipulation, and UUID conversion. No native addons; depends only on platform globals (`TextEncoder`, `atob`/`btoa`, `crypto.randomUUID`) and `@suaveplan/error` for typed errors.
<!-- begin:auto-docs -->
## Documentation tree
- [encoding](./src/encoding/encoding.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [encoding](./src/encoding/encoding.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [bytes-error](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [encoding](./src/encoding/encoding.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [bytes-error](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_- [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_- [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_- [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_- [encoding](./src/encoding/encoding.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [bytes-error](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_- [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_- [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_- [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_- [encoding](./src/encoding/encoding.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [bytes-error](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_- [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_- [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_- [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_- [encoding](./src/encoding/encoding.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [bytes-error](./src/errors/bytes-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_ - [uint8](./src/uint8/uint8.md) — <!-- begin:auto-docs --> _(no submodules)_- [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_ - [uuid](./src/uuid/uuid.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/bytes
```
## Quick Start
```ts
import {
bytesToHex, hexToBytes,
bytesToBase64, base64ToBytes,
bytesToUtf8, utf8ToBytes,
concatUint8Arrays,
convertDataFormat,
generateUuid, uuidToUint8Array, uint8ArrayToUuid,
} from "@suaveplan/bytes";
// Encoding
const raw = utf8ToBytes("Hello"); // Uint8Array [72,101,108,108,111]
const hex = bytesToHex(raw); // "48656c6c6f"
const b64 = bytesToBase64(raw); // "SGVsbG8="
const text = bytesToUtf8(raw); // "Hello"
// Uint8 helpers
const frame = concatUint8Arrays(raw, new Uint8Array([0x21]));
// Unified conversion
const asBase64 = convertDataFormat("48656c6c6f", "base64", "hex"); // "SGVsbG8="
// UUID
const id = generateUuid(); // "550e8400-e29b-41d4-a716-446655440000"
const bytes = uuidToUint8Array(id); // Uint8Array (16 bytes)
const back = uint8ArrayToUuid(bytes); // → id
```
## Modules
| Module | Description | Docs |
|--------|-------------|------|
| `@suaveplan/bytes/encoding` | Hex, base64, UTF-8 encode/decode | [encoding.md](src/encoding/encoding.md) |
| `@suaveplan/bytes/uint8` | Uint8Array utilities and format conversion | [uint8.md](src/uint8/uint8.md) |
| `@suaveplan/bytes/uuid` | UUID generation and binary conversion | [uuid.md](src/uuid/uuid.md) |
| `@suaveplan/bytes/errors` | `BytesError`, `BYTES_CODES` | — |
| `@suaveplan/bytes/schemas` | Zod schemas (HexStringSchema, etc.) | — |
## Benchmarks
Run with `bun src/__benchmarks__/encoding.bench.ts`.
```text
clk: ~2.74 GHz
cpu: Intel(R) Xeon(R) CPU E5-2696 v4 @ 2.20GHz
runtime: bun 1.3.13 (x64-linux)
benchmark avg (min … max)
bytesToHex (32 bytes) 9.31 µs/iter
bytesToHex (64 KB) 5.42 ms/iter
hexToBytes (32 bytes) 7.43 µs/iter
bytesToBase64 (32 bytes) 4.29 µs/iter
bytesToBase64 (64 KB) 983.62 µs/iter
```
## Error Codes
| Code | Thrown by |
|------|-----------|
| `BYTES.ENCODE.INVALID_HEX` | `hexToBytes` — odd length or non-hex chars |
| `BYTES.ENCODE.INVALID_BASE64` | `base64ToBytes` — invalid base64 alphabet |
| `BYTES.ENCODE.INVALID_UTF8` | Reserved |
| `BYTES.UINT8.INVALID_INPUT` | `inputToUint8Array` — unknown input type |
| `BYTES.UINT8.FORMAT_MISMATCH` | `validateInputTypeFormat` — type/format clash |
| `BYTES.UUID.INVALID_FORMAT` | `uuidToUint8Array` — malformed UUID string |
| `BYTES.UUID.INVALID_LENGTH` | `uint8ArrayToUuid` — not 16 bytes |

View File

@@ -0,0 +1,94 @@
{
"name": "@suaveplan/bytes",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Low-level byte utilities: hex/base64/utf8 encoding, Uint8Array helpers, UUID conversion",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./encoding": {
"import": {
"types": "./dist/encoding/index.d.ts",
"default": "./dist/encoding/index.js"
}
},
"./uint8": {
"import": {
"types": "./dist/uint8/index.d.ts",
"default": "./dist/uint8/index.js"
}
},
"./uuid": {
"import": {
"types": "./dist/uuid/index.d.ts",
"default": "./dist/uuid/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
}
},
"./schemas": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,103 @@
# encoding
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `encoding` module provides lossless, bidirectional conversion between raw `Uint8Array` byte sequences and three ubiquitous text representations: lowercase hexadecimal, standard base64, and UTF-8. It is the lowest-level I/O surface in `@suaveplan/bytes` and is intentionally free of any dependency beyond `@opentelemetry/api` and the `@suaveplan/error` error substrate.
Every conversion function validates its input strictly and throws a typed `BytesError` on malformed data, making it safe to use directly in protocol parsers, cryptographic pipelines, and data serialisation layers without additional guard code at the call site.
## Features
- **Hex encoding/decoding** — `bytesToHex` emits lowercase two-character hex pairs; `hexToBytes` accepts both upper- and lowercase and rejects odd-length or non-hex inputs with `BYTES.ENCODE.INVALID_HEX`.
- **Base64 encoding/decoding** — `bytesToBase64` uses the standard (non-URL-safe) alphabet; `base64ToBytes` validates the input against the standard alphabet before delegating to `atob`, rethrowing any decode error as `BYTES.ENCODE.INVALID_BASE64`.
- **UTF-8 encoding/decoding** — `utf8ToBytes` uses `TextEncoder` and `bytesToUtf8` uses `TextDecoder`, both handling multi-byte Unicode code points correctly.
- **OTel tracing** — every exported function wraps its body in a named active span under the `@suaveplan/bytes` tracer so encoding operations appear in distributed traces.
- **Zod schemas** — `HexStringSchema` and `Base64StringSchema` are exported from `encoding.schemas.ts` for use in Zod-based validation pipelines.
- **Property-tested** — fast-check verifies lossless round-trips for arbitrary byte sequences for all three codecs.
## Usage: Basic Example
```ts
import { bytesToHex, hexToBytes, bytesToBase64, base64ToBytes, utf8ToBytes, bytesToUtf8 } from "@suaveplan/bytes/encoding";
const raw = new Uint8Array([72, 101, 108, 108, 111]);
// Hex
const hex = bytesToHex(raw); // "48656c6c6f"
const back = hexToBytes(hex); // Uint8Array [72, 101, 108, 108, 111]
// Base64
const b64 = bytesToBase64(raw); // "SGVsbG8="
const back2 = base64ToBytes(b64); // Uint8Array [72, 101, 108, 108, 111]
// UTF-8
const text = bytesToUtf8(raw); // "Hello"
const back3 = utf8ToBytes(text); // Uint8Array [72, 101, 108, 108, 111]
```
## Usage: Advanced Example
```ts
import { hexToBytes, bytesToBase64, BytesError } from "@suaveplan/bytes";
// Safely parse an untrusted hex payload from an HTTP header
function parseHexHeader(value: string): Uint8Array {
try {
return hexToBytes(value.trim().toLowerCase());
} catch (err) {
if (err instanceof BytesError) {
// err.code === "BYTES.ENCODE.INVALID_HEX"
throw new Error(`Malformed X-Signature header: ${err.message}`);
}
throw err;
}
}
// Convert the raw bytes to base64 for JSON transport
const signature = parseHexHeader("deadbeef");
const forJson = bytesToBase64(signature); // "3q2+7w=="
```
## API Reference
### `bytesToHex(bytes: Uint8Array): string`
Converts `bytes` to a lowercase hexadecimal string. Each byte becomes exactly two hex characters. Returns `""` for an empty array.
### `hexToBytes(hex: string): Uint8Array`
Converts a hex string to a `Uint8Array`. Accepts upper- and lowercase characters. Returns an empty array for an empty string.
**Throws** `BytesError(BYTES.ENCODE.INVALID_HEX)` when:
- `hex.length` is odd.
- `hex` contains characters outside `[0-9a-fA-F]`.
### `bytesToBase64(bytes: Uint8Array): string`
Converts `bytes` to a standard base64 string (with `=` padding). Returns `""` for an empty array.
### `base64ToBytes(base64: string): Uint8Array`
Converts a standard base64 string to a `Uint8Array`. Returns an empty array for an empty string.
**Throws** `BytesError(BYTES.ENCODE.INVALID_BASE64)` when `base64` contains characters outside the standard base64 alphabet or the decode otherwise fails.
### `bytesToUtf8(bytes: Uint8Array): string`
Decodes `bytes` as a UTF-8 string using `TextDecoder`. Invalid sequences are replaced with the Unicode replacement character (U+FFFD) per platform behaviour.
### `utf8ToBytes(str: string): Uint8Array`
Encodes `str` as UTF-8 bytes using `TextEncoder`. Every Unicode code point, including surrogate pairs and multi-byte characters, is encoded correctly.
## Implementation Notes
The hex codec avoids regular-expression replacement in the hot encode path in favour of a simple loop over byte values with `toString(16).padStart(2, "0")`, which is both readable and cache-friendly on V8/JavaScriptCore. The decode path uses a single `HEX_RE.test()` validation pass before looping — this avoids per-character error checking while still catching all invalid inputs in one branch.
The base64 codec delegates to the platform `btoa`/`atob` globals (available natively in Node 18+ and Bun), avoiding any third-party encoding library. The encode path materialises a binary string from the byte array; the decode path pre-validates with a regex before calling `atob` and wraps any thrown exception in a `BytesError` so callers never see a raw `DOMException`.
OTel spans are started synchronously and ended in `finally` blocks, ensuring spans are always closed even when an exception propagates. The tracer is module-level so instrumentation is zero-cost when no SDK is installed (the `@opentelemetry/api` noop tracer is used automatically).

View File

@@ -0,0 +1,25 @@
import z from "zod";
/**
* Zod schema for a valid hexadecimal string.
*
* Accepts any even-length string consisting solely of hexadecimal digits
* (0–9, a–f, A–F). An empty string is valid and represents zero bytes.
*/
export const HexStringSchema = z
.string()
.refine((s) => s.length % 2 === 0 && /^[0-9a-fA-F]*$/.test(s), {
message: "Must be an even-length hexadecimal string",
});
/**
* Zod schema for a valid standard base64 string.
*
* Accepts the standard base64 alphabet (A–Z, a–z, 0–9, +, /) with optional
* `=` padding at the end. An empty string is valid.
*/
export const Base64StringSchema = z
.string()
.refine((s) => /^[A-Za-z0-9+/]*={0,2}$/.test(s), {
message: "Must be a valid base64-encoded string",
});

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import {
base64ToBytes,
bytesToBase64,
bytesToHex,
bytesToUtf8,
hexToBytes,
utf8ToBytes,
} from "./encoding.js";
describe("bytesToHex / hexToBytes", () => {
it("round-trips arbitrary bytes", { timeout: 5000 }, () => {
const input = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]);
expect(hexToBytes(bytesToHex(input))).toEqual(input);
});
it("bytesToHex produces lowercase hex", { timeout: 5000 }, () => {
expect(bytesToHex(new Uint8Array([255]))).toBe("ff");
});
it("bytesToHex handles empty array", { timeout: 5000 }, () => {
expect(bytesToHex(new Uint8Array())).toBe("");
});
it("hexToBytes handles empty string", { timeout: 5000 }, () => {
expect(hexToBytes("")).toEqual(new Uint8Array());
});
it("hexToBytes rejects odd-length string", { timeout: 5000 }, () => {
expect(() => hexToBytes("abc")).toThrow();
});
it("hexToBytes rejects non-hex chars", { timeout: 5000 }, () => {
expect(() => hexToBytes("zz")).toThrow();
});
it("hexToBytes rejects uppercase letters that are not hex", {
timeout: 5000,
}, () => {
expect(() => hexToBytes("GG")).toThrow();
});
it("hexToBytes accepts uppercase hex letters", { timeout: 5000 }, () => {
expect(hexToBytes("FF")).toEqual(new Uint8Array([255]));
});
it("hex round-trip for arbitrary bytes", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.uint8Array(), (bytes) => {
expect(hexToBytes(bytesToHex(bytes))).toEqual(bytes);
})
);
});
});
describe("bytesToBase64 / base64ToBytes", () => {
it("round-trips arbitrary bytes", { timeout: 5000 }, () => {
const input = new Uint8Array([1, 2, 3, 4, 5]);
expect(base64ToBytes(bytesToBase64(input))).toEqual(input);
});
it("handles empty array", { timeout: 5000 }, () => {
expect(bytesToBase64(new Uint8Array())).toBe("");
expect(base64ToBytes("")).toEqual(new Uint8Array());
});
it("rejects invalid base64", { timeout: 5000 }, () => {
expect(() => base64ToBytes("not!valid@base64")).toThrow();
});
it("rejects syntactically valid but malformed base64 (bad padding)", {
timeout: 5000,
}, () => {
// "a=" passes the regex but atob rejects it (requires groups of 4 chars)
expect(() => base64ToBytes("a=")).toThrow();
});
it("base64 round-trip for arbitrary bytes", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.uint8Array(), (bytes) => {
expect(base64ToBytes(bytesToBase64(bytes))).toEqual(bytes);
})
);
});
});
describe("bytesToUtf8 / utf8ToBytes", () => {
it("round-trips ASCII", { timeout: 5000 }, () => {
const text = "Hello, world!";
expect(bytesToUtf8(utf8ToBytes(text))).toBe(text);
});
it("round-trips multibyte characters", { timeout: 5000 }, () => {
const text = "こんにちは";
expect(bytesToUtf8(utf8ToBytes(text))).toBe(text);
});
it("handles empty string", { timeout: 5000 }, () => {
expect(bytesToUtf8(utf8ToBytes(""))).toBe("");
});
it("utf8 round-trip for arbitrary strings", { timeout: 30000 }, () => {
fc.assert(
fc.property(fc.string(), (str) => {
expect(bytesToUtf8(utf8ToBytes(str))).toBe(str);
})
);
});
});

View File

@@ -0,0 +1,162 @@
import { trace } from "@opentelemetry/api";
import { BYTES_CODES, BytesError } from "../errors/bytes-error.js";
const tracer = trace.getTracer("@suaveplan/bytes");
/** Regular expression matching a valid hex string (even-length, hex chars only). */
const HEX_RE = /^[0-9a-fA-F]*$/;
/** Regular expression matching a valid base64 string (standard alphabet + padding). */
const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
/**
* Encode a `Uint8Array` to a lowercase hexadecimal string.
*
* An empty array returns an empty string.
*/
export function bytesToHex(bytes: Uint8Array): string {
return tracer.startActiveSpan("bytes.encoding.bytesToHex", (span) => {
try {
let result = "";
for (let i = 0; i < bytes.length; i++) {
result += (bytes[i] as number).toString(16).padStart(2, "0");
}
return result;
} finally {
span.end();
}
});
}
/**
* Decode a hexadecimal string (case-insensitive) to a `Uint8Array`.
*
* Throws `BytesError` with code `BYTES.ENCODE.INVALID_HEX` when the input
* contains non-hex characters or has an odd length.
* An empty string returns an empty `Uint8Array`.
*/
export function hexToBytes(hex: string): Uint8Array {
return tracer.startActiveSpan("bytes.encoding.hexToBytes", (span) => {
try {
if (hex.length === 0) {
return new Uint8Array();
}
if (hex.length % 2 !== 0) {
throw new BytesError(
BYTES_CODES["ENCODE.INVALID_HEX"],
`Invalid hex string: odd length (${hex.length}). Hex strings must have an even number of characters.`
);
}
if (!HEX_RE.test(hex)) {
throw new BytesError(
BYTES_CODES["ENCODE.INVALID_HEX"],
"Invalid hex string: contains non-hexadecimal characters."
);
}
const result = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
result[i / 2] = Number.parseInt(hex.slice(i, i + 2), 16);
}
return result;
} finally {
span.end();
}
});
}
/**
* Encode a `Uint8Array` to a standard base64 string.
*
* An empty array returns an empty string.
*/
export function bytesToBase64(bytes: Uint8Array): string {
return tracer.startActiveSpan("bytes.encoding.bytesToBase64", (span) => {
try {
if (bytes.length === 0) {
return "";
}
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i] as number);
}
return btoa(binary);
} finally {
span.end();
}
});
}
/**
* Decode a standard base64 string to a `Uint8Array`.
*
* Throws `BytesError` with code `BYTES.ENCODE.INVALID_BASE64` when the input
* is not a valid base64 string.
* An empty string returns an empty `Uint8Array`.
*/
export function base64ToBytes(base64: string): Uint8Array {
return tracer.startActiveSpan("bytes.encoding.base64ToBytes", (span) => {
try {
if (base64.length === 0) {
return new Uint8Array();
}
if (!BASE64_RE.test(base64)) {
throw new BytesError(
BYTES_CODES["ENCODE.INVALID_BASE64"],
"Invalid base64 string: contains characters outside the standard base64 alphabet."
);
}
let binaryString: string;
try {
binaryString = atob(base64);
} catch {
throw new BytesError(
BYTES_CODES["ENCODE.INVALID_BASE64"],
"Invalid base64 string: failed to decode."
);
}
const result = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
result[i] = binaryString.charCodeAt(i);
}
return result;
} finally {
span.end();
}
});
}
/**
* Encode a `Uint8Array` to a UTF-8 string.
*
* Uses the platform `TextDecoder` for correct multi-byte character handling.
*/
export function bytesToUtf8(bytes: Uint8Array): string {
return tracer.startActiveSpan("bytes.encoding.bytesToUtf8", (span) => {
try {
return new TextDecoder("utf-8").decode(bytes);
} finally {
span.end();
}
});
}
/**
* Encode a UTF-8 string to a `Uint8Array`.
*
* Uses the platform `TextEncoder` for correct multi-byte character handling.
*/
export function utf8ToBytes(str: string): Uint8Array {
return tracer.startActiveSpan("bytes.encoding.utf8ToBytes", (span) => {
try {
return new TextEncoder().encode(str);
} finally {
span.end();
}
});
}

View File

@@ -0,0 +1,9 @@
export {
base64ToBytes,
bytesToBase64,
bytesToHex,
bytesToUtf8,
hexToBytes,
utf8ToBytes,
} from "./encoding.js";
export { Base64StringSchema, HexStringSchema } from "./encoding.schemas.js";

View File

@@ -0,0 +1,89 @@
# bytes-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`bytes-error.ts` defines the typed error substrate for `@suaveplan/bytes`. It registers all stable error codes under the `"BYTES"` namespace using `defineCodes` from `@suaveplan/error`, exposes the `BytesCode` union type that constrains which codes `BytesError` instances can carry, and re-exports the concrete `BytesError` class whose constructor widens visibility from `protected` to `public`.
By funnelling all package errors through a single typed class with a stable code registry, callers can narrow on `err instanceof BytesError` and then switch on `err.code` without any string fragility.
## Features
- **`BYTES_CODES`** — immutable record mapping human-readable dotted keys (e.g. `"ENCODE.INVALID_HEX"`) to stable prefixed string codes (`"BYTES.ENCODE.INVALID_HEX"`), produced by `defineCodes`.
- **`BytesCode`** — TypeScript union type of all values in `BYTES_CODES`; used as the generic parameter of `BytesError`.
- **`BytesError`** — extends `SuaveplanError<BytesCode>`; carries a stable `code`, a human-readable `message`, and an optional `cause` chain via `SuaveplanErrorOptions`.
- **Stable code registry** — codes are registered once and never change; adding a new code is a non-breaking addition; removing one is a breaking change.
## Usage: Basic Example
```ts
import { BytesError, BYTES_CODES } from "@suaveplan/bytes/errors";
throw new BytesError(
BYTES_CODES["ENCODE.INVALID_HEX"],
"Hex string has odd length: 3 characters"
);
```
## Usage: Advanced Example
```ts
import { BytesError, BYTES_CODES } from "@suaveplan/bytes/errors";
function assertHex(value: string): void {
if (value.length % 2 !== 0) {
throw new BytesError(
BYTES_CODES["ENCODE.INVALID_HEX"],
`Invalid hex string: odd length (${value.length})`,
{ cause: new RangeError("Length must be even") }
);
}
}
// Callers can narrow:
try {
assertHex("abc");
} catch (err) {
if (err instanceof BytesError) {
console.error(err.code); // "BYTES.ENCODE.INVALID_HEX"
}
}
```
## API Reference
### `BYTES_CODES`
`Record<string, BytesCode>` — maps dotted keys to prefixed code strings:
| Key | Code |
|-----|------|
| `"ENCODE.INVALID_HEX"` | `"BYTES.ENCODE.INVALID_HEX"` |
| `"ENCODE.INVALID_BASE64"` | `"BYTES.ENCODE.INVALID_BASE64"` |
| `"ENCODE.INVALID_UTF8"` | `"BYTES.ENCODE.INVALID_UTF8"` |
| `"UINT8.INVALID_INPUT"` | `"BYTES.UINT8.INVALID_INPUT"` |
| `"UINT8.FORMAT_MISMATCH"` | `"BYTES.UINT8.FORMAT_MISMATCH"` |
| `"UUID.INVALID_LENGTH"` | `"BYTES.UUID.INVALID_LENGTH"` |
| `"UUID.INVALID_FORMAT"` | `"BYTES.UUID.INVALID_FORMAT"` |
### `BytesCode`
`type BytesCode = (typeof BYTES_CODES)[keyof typeof BYTES_CODES]`
Union type of all code strings. Used as the generic argument to `SuaveplanError`.
### `BytesError`
```ts
class BytesError extends SuaveplanError<BytesCode> {
public constructor(code: BytesCode, message: string, options?: SuaveplanErrorOptions);
}
```
Typed error for all `@suaveplan/bytes` failures. The constructor widens visibility from the protected base to public.
## Implementation Notes
`defineCodes` from `@suaveplan/error` produces a frozen object whose values are the exact strings that will be serialised over the wire. The `"BYTES."` prefix guarantees no collision with codes from other packages. The `as const` assertion on the code-name array preserves tuple literal types so TypeScript can verify exhaustiveness in `switch (err.code)` statements.

View File

@@ -0,0 +1,32 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Error code registry for `@suaveplan/bytes`.
*/
export const BYTES_CODES = defineCodes("BYTES", [
"ENCODE.INVALID_HEX",
"ENCODE.INVALID_BASE64",
"ENCODE.INVALID_UTF8",
"UINT8.INVALID_INPUT",
"UINT8.FORMAT_MISMATCH",
"UUID.INVALID_LENGTH",
"UUID.INVALID_FORMAT",
] as const);
export type BytesCode = (typeof BYTES_CODES)[keyof typeof BYTES_CODES];
/** Typed error for `@suaveplan/bytes`. */
export class BytesError extends SuaveplanError<BytesCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens visibility
public constructor(
code: BytesCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
}
}

View File

@@ -0,0 +1 @@
export { BYTES_CODES, type BytesCode, BytesError } from "./bytes-error.js";

View File

@@ -0,0 +1,39 @@
// Errors
// Encoding
export {
Base64StringSchema,
base64ToBytes,
bytesToBase64,
bytesToHex,
bytesToUtf8,
HexStringSchema,
hexToBytes,
utf8ToBytes,
} from "./encoding/index.js";
export { BYTES_CODES, type BytesCode, BytesError } from "./errors/index.js";
// Types
export type { BytesInput, InputFormat, OutputFormat } from "./types/index.js";
// Uint8
export {
ConvertOptionsSchema,
concatUint8Arrays,
convertDataFormat,
detectInputFormat,
formatOutput,
InputFormatSchema,
inputToUint8Array,
OutputFormatSchema,
sliceUint8Array,
uint8ArraysEqual,
validateInputTypeFormat,
} from "./uint8/index.js";
// UUID
export {
generateUuid,
UuidStringSchema,
uint8ArrayToUuid,
uuidToUint8Array,
} from "./uuid/index.js";

View File

@@ -0,0 +1,5 @@
export type {
BytesInput,
InputFormat,
OutputFormat,
} from "./types.js";

View File

@@ -0,0 +1,78 @@
# types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`types.ts` defines the shared TypeScript types used across all modules in `@suaveplan/bytes`. These types form the public contract for format identifiers and accepted input values, allowing all three modules (`encoding`, `uint8`, `uuid`) to share a consistent vocabulary without any runtime code or circular dependencies.
Keeping shared types in a dedicated file — rather than scattering them across implementation files — makes the public API surface explicit and gives TypeScript enough information to provide accurate autocompletion and type narrowing at call sites.
## Features
- **`InputFormat`** — string literal union identifying the four recognised input encodings: `"hex"`, `"base64"`, `"utf8"`, `"uint8array"`.
- **`OutputFormat`** — string literal union identifying the four recognised output encodings (same set as `InputFormat`).
- **`BytesInput`** — union type accepting either a plain `string` or a `Uint8Array`; used as the input type for `inputToUint8Array`, `detectInputFormat`, and `convertDataFormat`.
## Usage: Basic Example
```ts
import type { InputFormat, OutputFormat, BytesInput } from "@suaveplan/bytes";
function toBytes(input: BytesInput, fmt: InputFormat): Uint8Array {
// TypeScript narrows input based on fmt
if (input instanceof Uint8Array) return input;
// ...
}
```
## Usage: Advanced Example
```ts
import type { OutputFormat, BytesInput } from "@suaveplan/bytes";
import { convertDataFormat } from "@suaveplan/bytes";
// A generic transform pipeline step
interface TransformStep {
input: BytesInput;
outputFormat: OutputFormat;
inputFormat?: import("@suaveplan/bytes").InputFormat;
}
function runStep(step: TransformStep): string | Uint8Array {
return convertDataFormat(step.input, step.outputFormat, step.inputFormat);
}
```
## API Reference
### `InputFormat`
```ts
type InputFormat = "hex" | "base64" | "utf8" | "uint8array";
```
Identifies the encoding of an input value passed to conversion functions. Used by `inputToUint8Array`, `detectInputFormat`, `validateInputTypeFormat`, and `convertDataFormat`.
### `OutputFormat`
```ts
type OutputFormat = "hex" | "base64" | "utf8" | "uint8array";
```
Identifies the encoding to produce from conversion functions. Used by `formatOutput` and `convertDataFormat`.
### `BytesInput`
```ts
type BytesInput = string | Uint8Array;
```
Union of the two accepted raw input types. When a `Uint8Array` is provided, no decoding step is needed. When a `string` is provided, the caller supplies an `InputFormat` or lets `detectInputFormat` infer it.
## Implementation Notes
`InputFormat` and `OutputFormat` are intentionally identical sets. They are defined as separate types rather than a single alias so that function signatures self-document the intended direction of flow (`input` vs. `output`), and so that future versions can diverge — for example, adding an `"url-base64"` output-only format — without affecting the input union.
There is no runtime code in this file. TypeScript erases all type declarations at compile time, so this module contributes zero bytes to the final bundle.

View File

@@ -0,0 +1,27 @@
/**
* Supported input encoding formats for byte conversion.
*
* - `"hex"` — lowercase hexadecimal string (e.g. `"48656c6c6f"`)
* - `"base64"` — standard base64-encoded string
* - `"utf8"` — UTF-8 text string
* - `"uint8array"` — raw `Uint8Array`
*/
export type InputFormat = "hex" | "base64" | "utf8" | "uint8array";
/**
* Supported output encoding formats for byte conversion.
*
* - `"hex"` — lowercase hexadecimal string
* - `"base64"` — standard base64-encoded string
* - `"utf8"` — UTF-8 text string
* - `"uint8array"` — raw `Uint8Array`
*/
export type OutputFormat = "hex" | "base64" | "utf8" | "uint8array";
/**
* Union of all accepted raw input types.
*
* Functions in `@suaveplan/bytes` accept either a plain `string`
* (format detected via `detectInputFormat`) or a raw `Uint8Array`.
*/
export type BytesInput = string | Uint8Array;

View File

@@ -0,0 +1,15 @@
export {
concatUint8Arrays,
convertDataFormat,
detectInputFormat,
formatOutput,
inputToUint8Array,
sliceUint8Array,
uint8ArraysEqual,
validateInputTypeFormat,
} from "./uint8.js";
export {
ConvertOptionsSchema,
InputFormatSchema,
OutputFormatSchema,
} from "./uint8.schemas.js";

View File

@@ -0,0 +1,113 @@
# uint8
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `uint8` module provides ergonomic, format-agnostic utilities for working with `Uint8Array` values: concatenation, slicing, equality testing, and format detection and conversion. It builds on the `encoding` module to offer a unified façade — `convertDataFormat` — that accepts any mix of hex, base64, UTF-8, or raw byte inputs and produces any requested output format, with automatic format detection when the caller omits an explicit hint.
The module is designed to sit at the heart of serialisation pipelines, protocol parsers, and data-transformation layers where the input format is heterogeneous or unknown at compile time.
## Features
- **`concatUint8Arrays`** — allocates a single result buffer and copies all source arrays in a single pass; accepts variadic arguments including zero arrays.
- **`sliceUint8Array`** — thin wrapper around `Uint8Array.prototype.slice` that returns a new copy (not a view) with consistent semantics.
- **`uint8ArraysEqual`** — linear-scan byte equality; safe for general-purpose comparison (not constant-time).
- **`detectInputFormat`** — heuristic: `Uint8Array` → `"uint8array"`, even-length all-hex string → `"hex"`, everything else → `"utf8"`.
- **`inputToUint8Array`** — normalise any `BytesInput` to a `Uint8Array`, calling the appropriate codec.
- **`validateInputTypeFormat`** — assert type/format compatibility and throw a typed `BytesError` on mismatch.
- **`formatOutput`** — serialise a `Uint8Array` to the desired `OutputFormat`.
- **`convertDataFormat`** — unified one-stop conversion with auto-detection, OTel instrumentation.
## Usage: Basic Example
```ts
import {
concatUint8Arrays,
uint8ArraysEqual,
convertDataFormat,
} from "@suaveplan/bytes/uint8";
// Concatenation
const header = new Uint8Array([0x01, 0x02]);
const body = new Uint8Array([0x03, 0x04, 0x05]);
const frame = concatUint8Arrays(header, body);
// → Uint8Array [1, 2, 3, 4, 5]
// Equality
uint8ArraysEqual(frame, new Uint8Array([1, 2, 3, 4, 5])); // → true
// Format conversion
const asHex = convertDataFormat(frame, "hex"); // → "0102030405"
const back = convertDataFormat(asHex, "uint8array", "hex");
// → Uint8Array [1, 2, 3, 4, 5]
```
## Usage: Advanced Example
```ts
import { convertDataFormat, detectInputFormat } from "@suaveplan/bytes/uint8";
// A pipeline that receives data in an unknown text format and normalises it
function normalise(raw: string): Uint8Array {
const fmt = detectInputFormat(raw);
console.log("detected format:", fmt); // "hex" | "utf8"
return convertDataFormat(raw, "uint8array") as Uint8Array;
}
// Produce multiple representations from one call
function multiView(input: Uint8Array) {
return {
hex: convertDataFormat(input, "hex") as string,
base64: convertDataFormat(input, "base64") as string,
utf8: convertDataFormat(input, "utf8") as string,
};
}
```
## API Reference
### `concatUint8Arrays(...arrays: Uint8Array[]): Uint8Array`
Concatenate zero or more `Uint8Array` values into a single new `Uint8Array`. Allocates exactly one buffer sized to the sum of all input lengths.
### `sliceUint8Array(arr: Uint8Array, start: number, end?: number): Uint8Array`
Return a copy of `arr[start..end)`. If `end` is omitted, slices to the end of the array. Returns an empty `Uint8Array` when `start === end`.
### `uint8ArraysEqual(a: Uint8Array, b: Uint8Array): boolean`
Return `true` iff `a.length === b.length` and every byte is identical. Not constant-time.
### `detectInputFormat(input: BytesInput): InputFormat`
Heuristically determine the format of `input`. `Uint8Array` → `"uint8array"`. Even-length all-hex string → `"hex"`. Otherwise → `"utf8"`.
### `inputToUint8Array(input: BytesInput, format?: InputFormat): Uint8Array`
Convert `input` to a `Uint8Array`. If `input` is already a `Uint8Array`, returns it unchanged. For strings, uses `format` or `detectInputFormat(input)`.
**Throws** `BytesError(BYTES.UINT8.FORMAT_MISMATCH)` when `format === "uint8array"` but `input` is a string.
### `validateInputTypeFormat(input: BytesInput, format: InputFormat): void`
Assert that the runtime type of `input` is compatible with `format`.
**Throws** `BytesError(BYTES.UINT8.FORMAT_MISMATCH)` on mismatch.
### `formatOutput(bytes: Uint8Array, format: OutputFormat): string | Uint8Array`
Serialise `bytes` to the given output format. Returns the same reference for `"uint8array"`.
### `convertDataFormat(input: BytesInput, outputFormat: OutputFormat, inputFormat?: InputFormat): string | Uint8Array`
Convert `input` from `inputFormat` (or auto-detected) to `outputFormat`. Wraps the operation in an OTel span named `bytes.uint8.convertDataFormat`.
## Implementation Notes
`concatUint8Arrays` pre-computes the total length in a single reduce pass and writes all sub-arrays via `Uint8Array.prototype.set`, which is optimised as a memory copy by V8 and JavaScriptCore. This is significantly faster than repeated array spreading or `Buffer.concat`-style approaches.
`detectInputFormat` uses a two-condition check: even length and a regex over the full string. The hex heuristic will misclassify a legitimate UTF-8 string that happens to be even-length and consist entirely of hex chars (e.g., `"deadbeef"`). Callers that know the format should pass it explicitly. A future version may accept a `strict` option that requires a `0x` prefix for unambiguous hex detection.
`uint8ArraysEqual` is a simple linear scan and is deliberately **not** constant-time. Do not use it to compare MACs or cryptographic secrets — use the constant-time comparison from `@suaveplan/crypto-utils` for that purpose.

View File

@@ -0,0 +1,29 @@
import z from "zod";
/**
* Zod schema for the accepted input formats.
*/
export const InputFormatSchema = z.enum([
"hex",
"base64",
"utf8",
"uint8array",
]);
/**
* Zod schema for the accepted output formats.
*/
export const OutputFormatSchema = z.enum([
"hex",
"base64",
"utf8",
"uint8array",
]);
/**
* Zod schema that validates options passed to `convertDataFormat`.
*/
export const ConvertOptionsSchema = z.object({
inputFormat: InputFormatSchema.optional(),
outputFormat: OutputFormatSchema,
});

View File

@@ -0,0 +1,238 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import {
concatUint8Arrays,
convertDataFormat,
detectInputFormat,
formatOutput,
inputToUint8Array,
sliceUint8Array,
uint8ArraysEqual,
validateInputTypeFormat,
} from "./uint8.js";
describe("concatUint8Arrays", () => {
it("concatenates multiple arrays", { timeout: 5000 }, () => {
const a = new Uint8Array([1, 2]);
const b = new Uint8Array([3, 4]);
const c = new Uint8Array([5]);
expect(concatUint8Arrays(a, b, c)).toEqual(
new Uint8Array([1, 2, 3, 4, 5])
);
});
it("handles empty arrays", { timeout: 5000 }, () => {
expect(
concatUint8Arrays(new Uint8Array(), new Uint8Array([1]))
).toEqual(new Uint8Array([1]));
});
it("handles single array", { timeout: 5000 }, () => {
const a = new Uint8Array([1, 2, 3]);
expect(concatUint8Arrays(a)).toEqual(a);
});
it("handles no arguments", { timeout: 5000 }, () => {
expect(concatUint8Arrays()).toEqual(new Uint8Array());
});
it("concatUint8Arrays associativity", { timeout: 30000 }, () => {
fc.assert(
fc.property(
fc.uint8Array(),
fc.uint8Array(),
fc.uint8Array(),
(a, b, c) => {
const left = concatUint8Arrays(concatUint8Arrays(a, b), c);
const right = concatUint8Arrays(a, concatUint8Arrays(b, c));
expect(left).toEqual(right);
}
)
);
});
});
describe("sliceUint8Array", () => {
it("slices correctly", { timeout: 5000 }, () => {
const arr = new Uint8Array([1, 2, 3, 4, 5]);
expect(sliceUint8Array(arr, 1, 3)).toEqual(new Uint8Array([2, 3]));
});
it("slices from start only", { timeout: 5000 }, () => {
const arr = new Uint8Array([1, 2, 3, 4, 5]);
expect(sliceUint8Array(arr, 2)).toEqual(new Uint8Array([3, 4, 5]));
});
it("returns empty for zero-length slice", { timeout: 5000 }, () => {
const arr = new Uint8Array([1, 2, 3]);
expect(sliceUint8Array(arr, 1, 1)).toEqual(new Uint8Array());
});
});
describe("uint8ArraysEqual", () => {
it("returns true for equal arrays", { timeout: 5000 }, () => {
expect(
uint8ArraysEqual(
new Uint8Array([1, 2, 3]),
new Uint8Array([1, 2, 3])
)
).toBe(true);
});
it("returns false for different arrays", { timeout: 5000 }, () => {
expect(
uint8ArraysEqual(
new Uint8Array([1, 2, 3]),
new Uint8Array([1, 2, 4])
)
).toBe(false);
});
it("returns false for different lengths", { timeout: 5000 }, () => {
expect(
uint8ArraysEqual(new Uint8Array([1, 2]), new Uint8Array([1, 2, 3]))
).toBe(false);
});
it("returns true for two empty arrays", { timeout: 5000 }, () => {
expect(uint8ArraysEqual(new Uint8Array(), new Uint8Array())).toBe(true);
});
});
describe("detectInputFormat", () => {
it("detects Uint8Array", { timeout: 5000 }, () => {
expect(detectInputFormat(new Uint8Array())).toBe("uint8array");
});
it("detects hex string", { timeout: 5000 }, () => {
expect(detectInputFormat("48656c6c6f")).toBe("hex");
});
it("detects utf8 for general string", { timeout: 5000 }, () => {
expect(detectInputFormat("Hello, world!")).toBe("utf8");
});
it("detects utf8 for non-hex even string", { timeout: 5000 }, () => {
expect(detectInputFormat("not-hex-string")).toBe("utf8");
});
});
describe("inputToUint8Array", () => {
it("returns Uint8Array as-is", { timeout: 5000 }, () => {
const arr = new Uint8Array([1, 2, 3]);
expect(inputToUint8Array(arr)).toBe(arr);
});
it("converts hex string to Uint8Array", { timeout: 5000 }, () => {
expect(inputToUint8Array("48656c6c6f", "hex")).toEqual(
new Uint8Array([72, 101, 108, 108, 111])
);
});
it("converts utf8 string to Uint8Array", { timeout: 5000 }, () => {
expect(inputToUint8Array("Hello", "utf8")).toEqual(
new Uint8Array([72, 101, 108, 108, 111])
);
});
it("converts base64 string to Uint8Array", { timeout: 5000 }, () => {
expect(inputToUint8Array("SGVsbG8=", "base64")).toEqual(
new Uint8Array([72, 101, 108, 108, 111])
);
});
it("auto-detects format when not provided", { timeout: 5000 }, () => {
// "48656c6c6f" is valid hex
const result = inputToUint8Array("48656c6c6f");
expect(result).toEqual(new Uint8Array([72, 101, 108, 108, 111]));
});
it("throws when string given with uint8array format", {
timeout: 5000,
}, () => {
expect(() => inputToUint8Array("hello", "uint8array")).toThrow();
});
});
describe("validateInputTypeFormat", () => {
it("validates Uint8Array with uint8array format", { timeout: 5000 }, () => {
expect(() =>
validateInputTypeFormat(new Uint8Array(), "uint8array")
).not.toThrow();
});
it("validates string with hex format", { timeout: 5000 }, () => {
expect(() => validateInputTypeFormat("ff", "hex")).not.toThrow();
});
it("throws when Uint8Array given but string format requested", {
timeout: 5000,
}, () => {
expect(() =>
validateInputTypeFormat(new Uint8Array(), "hex")
).toThrow();
});
it("throws when string given but uint8array format requested", {
timeout: 5000,
}, () => {
expect(() => validateInputTypeFormat("hello", "uint8array")).toThrow();
});
});
describe("formatOutput", () => {
it("returns Uint8Array for uint8array format", { timeout: 5000 }, () => {
const arr = new Uint8Array([1, 2, 3]);
const result = formatOutput(arr, "uint8array");
expect(result).toBe(arr);
});
it("returns hex string for hex format", { timeout: 5000 }, () => {
expect(formatOutput(new Uint8Array([255]), "hex")).toBe("ff");
});
it("returns base64 string for base64 format", { timeout: 5000 }, () => {
expect(
formatOutput(new Uint8Array([72, 101, 108, 108, 111]), "base64")
).toBe("SGVsbG8=");
});
it("returns utf8 string for utf8 format", { timeout: 5000 }, () => {
expect(
formatOutput(new Uint8Array([72, 101, 108, 108, 111]), "utf8")
).toBe("Hello");
});
});
describe("convertDataFormat", () => {
it("converts hex to utf8", { timeout: 5000 }, () => {
expect(convertDataFormat("48656c6c6f", "utf8", "hex")).toBe("Hello");
});
it("converts utf8 to hex", { timeout: 5000 }, () => {
expect(convertDataFormat("Hello", "hex", "utf8")).toBe("48656c6c6f");
});
it("converts Uint8Array to hex", { timeout: 5000 }, () => {
expect(
convertDataFormat(new Uint8Array([72, 101, 108, 108, 111]), "hex")
).toBe("48656c6c6f");
});
it("converts hex to base64", { timeout: 5000 }, () => {
expect(convertDataFormat("48656c6c6f", "base64", "hex")).toBe(
"SGVsbG8="
);
});
it("converts Uint8Array to uint8array (identity)", {
timeout: 5000,
}, () => {
const arr = new Uint8Array([1, 2, 3]);
expect(convertDataFormat(arr, "uint8array")).toBe(arr);
});
it("auto-detects input format for hex strings", { timeout: 5000 }, () => {
expect(convertDataFormat("48656c6c6f", "utf8")).toBe("Hello");
});
});

View File

@@ -0,0 +1,195 @@
import { trace } from "@opentelemetry/api";
import {
base64ToBytes,
bytesToBase64,
bytesToHex,
bytesToUtf8,
hexToBytes,
utf8ToBytes,
} from "../encoding/encoding.js";
import { BYTES_CODES, BytesError } from "../errors/bytes-error.js";
import type { BytesInput, InputFormat, OutputFormat } from "../types/types.js";
const tracer = trace.getTracer("@suaveplan/bytes");
/** Regex for even-length all-hex strings. */
const HEX_PATTERN = /^[0-9a-fA-F]+$/;
/**
* Concatenate any number of `Uint8Array` instances into a new `Uint8Array`.
*
* Returns an empty `Uint8Array` when called with no arguments.
*/
export function concatUint8Arrays(...arrays: Uint8Array[]): Uint8Array {
const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const arr of arrays) {
result.set(arr, offset);
offset += arr.length;
}
return result;
}
/**
* Return a slice of `arr` from `start` (inclusive) to `end` (exclusive).
*
* Semantics mirror `Array.prototype.slice` — negative indices are not
* supported; pass only non-negative integers.
*/
export function sliceUint8Array(
arr: Uint8Array,
start: number,
end?: number
): Uint8Array {
return arr.slice(start, end);
}
/**
* Return `true` when `a` and `b` have the same length and identical byte
* values at every position.
*
* This comparison is **not** constant-time — do not use it for
* cryptographic MAC verification. Use `@suaveplan/crypto-utils` for
* constant-time equality.
*/
export function uint8ArraysEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
/**
* Detect the most likely `InputFormat` of `input`.
*
* - `Uint8Array` → `"uint8array"`
* - Even-length string composed entirely of hex characters → `"hex"`
* - Everything else → `"utf8"`
*
* This heuristic is intentionally conservative: a string that looks like hex
* (even length, all hex chars) is classified as hex. If the caller knows the
* format, pass it explicitly to `inputToUint8Array`.
*/
export function detectInputFormat(input: BytesInput): InputFormat {
if (input instanceof Uint8Array) {
return "uint8array";
}
if (input.length > 0 && input.length % 2 === 0 && HEX_PATTERN.test(input)) {
return "hex";
}
return "utf8";
}
/**
* Assert that `input`'s runtime type is compatible with `format`.
*
* - `"uint8array"` requires `input` to be a `Uint8Array`.
* - `"hex"`, `"base64"`, `"utf8"` require `input` to be a `string`.
*
* Throws `BytesError(BYTES.UINT8.FORMAT_MISMATCH)` on mismatch.
*/
export function validateInputTypeFormat(
input: BytesInput,
format: InputFormat
): void {
const isUint8 = input instanceof Uint8Array;
if (format === "uint8array" && !isUint8) {
throw new BytesError(
BYTES_CODES["UINT8.FORMAT_MISMATCH"],
`Format "uint8array" requires a Uint8Array input, but received a string.`
);
}
if (format !== "uint8array" && isUint8) {
throw new BytesError(
BYTES_CODES["UINT8.FORMAT_MISMATCH"],
`Format "${format}" requires a string input, but received a Uint8Array.`
);
}
}
/**
* Convert `input` to a `Uint8Array`.
*
* When `format` is omitted and `input` is a string, `detectInputFormat` is
* used to infer the encoding.
*
* Throws `BytesError(BYTES.UINT8.INVALID_INPUT)` when `input` is neither a
* `string` nor a `Uint8Array`.
*/
export function inputToUint8Array(
input: BytesInput,
format?: InputFormat
): Uint8Array {
if (input instanceof Uint8Array) {
return input;
}
const resolvedFormat: InputFormat = format ?? detectInputFormat(input);
switch (resolvedFormat) {
case "hex":
return hexToBytes(input);
case "base64":
return base64ToBytes(input);
case "utf8":
return utf8ToBytes(input);
case "uint8array":
throw new BytesError(
BYTES_CODES["UINT8.FORMAT_MISMATCH"],
`Format "uint8array" requires a Uint8Array, but received a string.`
);
}
}
/**
* Serialise `bytes` to the requested `OutputFormat`.
*
* - `"uint8array"` returns the same reference unchanged.
* - `"hex"` returns a lowercase hex string.
* - `"base64"` returns a standard base64 string.
* - `"utf8"` returns a UTF-8 decoded string.
*/
export function formatOutput(
bytes: Uint8Array,
format: OutputFormat
): string | Uint8Array {
switch (format) {
case "uint8array":
return bytes;
case "hex":
return bytesToHex(bytes);
case "base64":
return bytesToBase64(bytes);
case "utf8":
return bytesToUtf8(bytes);
}
}
/**
* Convert `input` from `inputFormat` (or auto-detected) to `outputFormat`.
*
* This is the primary façade for cross-format conversion:
*
* ```ts
* convertDataFormat("48656c6c6f", "utf8", "hex") // → "Hello"
* convertDataFormat("Hello", "hex", "utf8") // → "48656c6c6f"
* convertDataFormat(bytes, "base64") // → "SGVsbG8="
* ```
*
* When `inputFormat` is omitted and `input` is a string, `detectInputFormat`
* is called automatically.
*/
export function convertDataFormat(
input: BytesInput,
outputFormat: OutputFormat,
inputFormat?: InputFormat
): string | Uint8Array {
return tracer.startActiveSpan("bytes.uint8.convertDataFormat", (span) => {
try {
const bytes = inputToUint8Array(input, inputFormat);
return formatOutput(bytes, outputFormat);
} finally {
span.end();
}
});
}

View File

@@ -0,0 +1,2 @@
export { generateUuid, uint8ArrayToUuid, uuidToUint8Array } from "./uuid.js";
export { UuidStringSchema } from "./uuid.schemas.js";

View File

@@ -0,0 +1,97 @@
# uuid
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `uuid` module provides three focused primitives for working with Universally Unique Identifiers in `@suaveplan/bytes`: generating cryptographically random UUIDv4 strings, converting canonical UUID strings to their compact 16-byte binary representation, and converting a 16-byte `Uint8Array` back to the canonical hyphen-separated string. These operations are essential for any system that stores entity identifiers as binary blobs in databases, network protocols, or binary serialisation formats while presenting them as human-readable strings at the API boundary.
All three functions validate their inputs strictly and throw typed `BytesError` values on malformed data, making them safe to use directly in request handlers and protocol parsers.
## Features
- **`generateUuid`** — generates a UUIDv4 string using the platform `crypto.randomUUID()` — no third-party uuid library, no custom random-number manipulation.
- **`uuidToUint8Array`** — parses a canonical UUID string into exactly 16 bytes; validates format with a regex before any byte parsing.
- **`uint8ArrayToUuid`** — formats a 16-byte `Uint8Array` as a canonical lowercase UUID string; validates length before formatting.
- **OTel tracing** — every function is wrapped in a named active span under the `@suaveplan/bytes` tracer.
- **Zod schema** — `UuidStringSchema` validates the canonical UUID format and is composable with any Zod object schema.
## Usage: Basic Example
```ts
import { generateUuid, uuidToUint8Array, uint8ArrayToUuid } from "@suaveplan/bytes/uuid";
// Generate a fresh UUIDv4
const id = generateUuid();
// → "550e8400-e29b-41d4-a716-446655440000" (random each call)
// Convert to binary for compact storage
const bytes = uuidToUint8Array(id);
// → Uint8Array of length 16
// Restore the canonical string
const restored = uint8ArrayToUuid(bytes);
// → same as `id`
```
## Usage: Advanced Example
```ts
import { uuidToUint8Array, uint8ArrayToUuid, BytesError } from "@suaveplan/bytes";
import { UuidStringSchema } from "@suaveplan/bytes/uuid";
import z from "zod";
// Validate and convert an incoming UUID header to binary for DB storage
const RequestSchema = z.object({
entityId: UuidStringSchema,
});
function handleRequest(raw: unknown): Uint8Array {
const { entityId } = RequestSchema.parse(raw);
try {
return uuidToUint8Array(entityId);
} catch (err) {
if (err instanceof BytesError) {
// err.code === "BYTES.UUID.INVALID_FORMAT"
throw new Error(`Malformed entity ID: ${err.message}`);
}
throw err;
}
}
// Convert back to string for API responses
function toApiResponse(raw: Uint8Array): string {
return uint8ArrayToUuid(raw);
}
```
## API Reference
### `generateUuid(): string`
Generate a UUID version 4 string using `crypto.randomUUID()`. Output is always lowercase and matches the pattern `/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/`.
### `uuidToUint8Array(uuid: string): Uint8Array`
Parse a canonical UUID string (upper- or lowercase) into a 16-byte `Uint8Array`. Strips the four hyphens, then reads each pair of hex digits as a byte.
**Throws** `BytesError(BYTES.UUID.INVALID_FORMAT)` when `uuid` does not match the canonical pattern `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`.
### `uint8ArrayToUuid(bytes: Uint8Array): string`
Format a 16-byte `Uint8Array` as a canonical lowercase UUID string in the form `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`.
**Throws** `BytesError(BYTES.UUID.INVALID_LENGTH)` when `bytes.length !== 16`.
### `UuidStringSchema` (from `uuid.schemas.ts`)
Zod schema that validates the canonical UUID format. Accepts both upper- and lowercase hex digits. Does not verify UUID version or variant bits.
## Implementation Notes
`generateUuid` delegates entirely to the platform `crypto.randomUUID()` (Node 14.17+, Bun, all modern browsers), ensuring the UUID is generated from a cryptographically secure source of randomness with no custom bit-twiddling. A polyfill that sets `globalThis.crypto = require("node:crypto").webcrypto` is included in the package test setup so that Node `forks`-pool Vitest runs have access to the `crypto` global.
`uuidToUint8Array` validates the input with a compiled regex before any byte parsing. This rejects inputs that are structurally wrong (wrong group lengths, non-hex chars, missing hyphens) with a single check rather than per-byte error detection.
`uint8ArrayToUuid` materialises a hex string from the byte array, then splices in the four hyphens at the standard offsets (8, 12, 16, 20 hex chars). Output is always lowercase because `Number.prototype.toString(16)` produces lowercase hex on all platforms.

View File

@@ -0,0 +1,15 @@
import z from "zod";
/**
* Zod schema for a canonical UUID string.
*
* Validates the format `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` where each `x`
* is a hexadecimal digit (0–9, a–f, A–F). Does not verify the version or
* variant bits — use `generateUuid` to obtain a guaranteed UUIDv4.
*/
export const UuidStringSchema = z
.string()
.regex(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
"Must be a valid UUID string in canonical form"
);

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { generateUuid, uint8ArrayToUuid, uuidToUint8Array } from "./uuid.js";
describe("uuid round-trip", () => {
it("round-trips a known UUID", { timeout: 5000 }, () => {
const uuid = "550e8400-e29b-41d4-a716-446655440000";
expect(uint8ArrayToUuid(uuidToUint8Array(uuid))).toBe(uuid);
});
it("uuidToUint8Array produces 16 bytes", { timeout: 5000 }, () => {
expect(
uuidToUint8Array("550e8400-e29b-41d4-a716-446655440000")
).toHaveLength(16);
});
it("throws on invalid UUID", { timeout: 5000 }, () => {
expect(() => uuidToUint8Array("not-a-uuid")).toThrow();
});
it("throws on UUID with wrong structure", { timeout: 5000 }, () => {
expect(() =>
uuidToUint8Array("550e8400e29b41d4a716446655440000")
).toThrow();
});
it("uint8ArrayToUuid throws on non-16-byte array", {
timeout: 5000,
}, () => {
expect(() => uint8ArrayToUuid(new Uint8Array(15))).toThrow();
});
it("uint8ArrayToUuid throws on empty array", { timeout: 5000 }, () => {
expect(() => uint8ArrayToUuid(new Uint8Array(0))).toThrow();
});
it("round-trips all-zeros UUID", { timeout: 5000 }, () => {
const uuid = "00000000-0000-0000-0000-000000000000";
expect(uint8ArrayToUuid(uuidToUint8Array(uuid))).toBe(uuid);
});
it("round-trips all-fs UUID", { timeout: 5000 }, () => {
const uuid = "ffffffff-ffff-ffff-ffff-ffffffffffff";
expect(uint8ArrayToUuid(uuidToUint8Array(uuid))).toBe(uuid);
});
});
describe("generateUuid", () => {
it("generates a valid UUID v4 string", { timeout: 5000 }, () => {
const uuid = generateUuid();
expect(uuid).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
);
});
it("generates unique values", { timeout: 5000 }, () => {
expect(generateUuid()).not.toBe(generateUuid());
});
it("output is lowercase", { timeout: 5000 }, () => {
const uuid = generateUuid();
expect(uuid).toBe(uuid.toLowerCase());
});
it("generated UUID can round-trip through uint8Array", {
timeout: 5000,
}, () => {
const uuid = generateUuid();
expect(uint8ArrayToUuid(uuidToUint8Array(uuid))).toBe(uuid);
});
});

View File

@@ -0,0 +1,87 @@
import { trace } from "@opentelemetry/api";
import { BYTES_CODES, BytesError } from "../errors/bytes-error.js";
const tracer = trace.getTracer("@suaveplan/bytes");
/** UUID canonical format: 8-4-4-4-12 lowercase hex groups separated by hyphens. */
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Generate a UUID v4 string using the platform `crypto.randomUUID()`.
*
* The output is always lowercase and conforms to the canonical
* `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx` format.
*/
export function generateUuid(): string {
return tracer.startActiveSpan("bytes.uuid.generateUuid", (span) => {
try {
return crypto.randomUUID();
} finally {
span.end();
}
});
}
/**
* Parse a canonical UUID string into a 16-byte `Uint8Array`.
*
* Accepts both upper- and lowercase hex. Dashes are stripped before parsing.
*
* @throws `BytesError(BYTES.UUID.INVALID_FORMAT)` when `uuid` does not
* match the `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` pattern.
*/
export function uuidToUint8Array(uuid: string): Uint8Array {
return tracer.startActiveSpan("bytes.uuid.uuidToUint8Array", (span) => {
try {
if (!UUID_RE.test(uuid)) {
throw new BytesError(
BYTES_CODES["UUID.INVALID_FORMAT"],
`Invalid UUID format: "${uuid}". Expected canonical form xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.`
);
}
const hex = uuid.replace(/-/g, "");
const result = new Uint8Array(16);
for (let i = 0; i < 16; i++) {
result[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return result;
} finally {
span.end();
}
});
}
/**
* Format a 16-byte `Uint8Array` as a canonical lowercase UUID string.
*
* @throws `BytesError(BYTES.UUID.INVALID_LENGTH)` when `bytes` is not
* exactly 16 bytes long.
*/
export function uint8ArrayToUuid(bytes: Uint8Array): string {
return tracer.startActiveSpan("bytes.uuid.uint8ArrayToUuid", (span) => {
try {
if (bytes.length !== 16) {
throw new BytesError(
BYTES_CODES["UUID.INVALID_LENGTH"],
`Invalid UUID byte array length: ${bytes.length}. Expected exactly 16 bytes.`
);
}
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),
].join("-");
} finally {
span.end();
}
});
}

View File

@@ -0,0 +1,8 @@
// Ensure `globalThis.crypto` is available in bare-Node vitest forks runs.
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/bytes",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/*.schemas.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
"tests/setup/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});