
# Codecs

> Hex, base64 and base32 as `stringify` / `parse` pairs, strict by default, with one contract on every runtime.

::note
**ELI5.** Bytes are not text. To put them in a URL, a header, a JSON field or a QR code you have to spell them out in characters, and these are the three spellings everyone uses: hex, base64, base32. Each codec has two halves. `stringify` turns bytes into text; `parse` turns text back into bytes. `parse` is strict on purpose: it accepts exactly the canonical spelling and refuses everything else, because two different texts decoding to the same bytes is how signature checks get bypassed. When the text came from a human who may have pasted it with spaces, pass `{ loose: true }`.
::

```ts
import { hexStringify, hexParse } from "unsecure/utils";

hexStringify(bytes); // "deadbeef…"
hexParse("deadbeef", { returnAs: "bytes" }); // Uint8Array
```

Everything here lives at `unsecure/utils` and is re-exported from the barrel.

## Flat functions and grouping objects

Each codec is exported twice: as two flat functions, and grouped JSON-style into an object.

```ts
import {
  hexStringify,
  hexParse,
  base64Stringify,
  base64Parse,
  base32Stringify,
  base32Parse,
  Hex,
  Base64,
  Base32,
  textEncoder,
  textDecoder,
} from "unsecure/utils";

Hex.stringify === hexStringify; // true
```

They are the same functions. The difference is what a bundle ends up carrying: importing `base64Parse` on its own ships one codec, while importing `Base64` reaches the grouping object and, through the barrel, can pull the hex and base32 tables in with it. Prefer the flat functions in a browser bundle; use whichever reads better on a server.

For a CDN, name the subpath — `https://esm.sh/unsecure/utils` ships these helpers and nothing else.

## The shared contract

**`stringify(data, options?)`** takes a `string`, which is UTF-8 encoded first, or any `BytesSource`: an `ArrayBuffer` (shared or not), a `DataView`, or any typed array. It returns the encoded text. `null` or `undefined` is `INVALID_TYPE`.

**`parse(input, options?)`** takes the encoded `string`, or a `Uint8Array` of that text's bytes. It returns bytes or a UTF-8 string.

`returnAs` mirrors the input when omitted: a `string` in gives a `string` out, a `Uint8Array` in gives a `Uint8Array` out. Override with `{ returnAs: "string" | "uint8array" | "bytes" }`, where `"bytes"` is an alias for `"uint8array"`.

Byte output always owns an `ArrayBuffer` exactly its own length. It is never a view into a pool or a longer scratch buffer.

## Hex

```ts
hexStringify("hello"); // "68656c6c6f"
hexStringify(new Uint8Array([0xde, 0xad])); // "dead"

hexParse("68656c6c6f"); // "hello"
hexParse("68656c6c6f", { returnAs: "bytes" }); // Uint8Array [104, 101, 108, 108, 111]

hexParse("zz");
// UnsecureError MALFORMED: Hex.parse: invalid hexadecimal character "z" at index 0.
hexParse("de ad");
// UnsecureError MALFORMED: Hex.parse: invalid hexadecimal character " " at index 2.

hexParse("de ad", { loose: true, returnAs: "bytes" }); // Uint8Array [222, 173]
hexParse("abc", { loose: true, returnAs: "bytes" }); // Uint8Array [171] — the odd nibble is dropped
```

## Base64

Standard alphabet by default, padded. `{ alphabet: "base64url" }` switches to the URL-safe `-_` alphabet, which is unpadded by default. `{ padding: false }` drops `=` on any alphabet.

```ts
base64Stringify(new Uint8Array([1, 2, 3])); // "AQID"
base64Stringify("hello world"); // "aGVsbG8gd29ybGQ="
base64Stringify("hello world", { padding: false }); // "aGVsbG8gd29ybGQ"
base64Stringify("hello?world~", { alphabet: "base64url" }); // "aGVsbG8_d29ybGR-"

base64Parse("aGVsbG8gd29ybGQ="); // "hello world"
base64Parse("Zm9vYg"); // "foob" — unpadded is canonical too
base64Parse(token, { alphabet: "base64url" }); // strict URL-safe decode
base64Parse(untrusted, { loose: true }); // tolerant; accepts either alphabet
```

## Base32

`alphabet` takes `"base32"` (RFC 4648, the default), `"base32hex"`, `"crockford"`, or a custom 32-character string. Padded by default, except Crockford; `{ padding: false }` overrides.

```ts
base32Stringify("foobar"); // "MZXW6YTBOI======"
base32Stringify("foobar", { padding: false }); // "MZXW6YTBOI"
base32Stringify("foobar", { alphabet: "crockford" }); // "CSQPYRK1E8"

base32Parse("MZXW6YTBOI"); // "foobar"
base32Parse("MZXW6YTBOI", { returnAs: "bytes" }); // raw bytes
base32Parse(id, { alphabet: "crockford" });
```

Strict decoding is uppercase-only for `base32` and `base32hex`; `{ loose: true }` folds case. Crockford is case-insensitive in both modes and maps `O` to 0, `I` and `L` to 1, per that alphabet's own spec. A custom alphabet is taken literally in both modes, because its case may carry meaning.

A custom alphabet must be 32 distinct ASCII characters, none of them `=` or whitespace. Anything else is `OUT_OF_RANGE`: the alphabet is configuration you chose, not text the codec was asked to read.

## Strict and loose

`parse` is strict by default to avoid decode malleability, which is two different texts decoding to the same bytes.

**Strict accepts exactly the canonical encoding of some byte string:**

- Characters from the selected alphabet only. Whitespace is a character like any other, and is rejected.
- `=` only as a trailing run, and only in the count the body length calls for, or absent entirely. Unpadded is canonical, so anything `stringify` emits round-trips, `{ padding: false }` and the unpadded `base64url` default included.
- A length that can encode whole bytes. `"Zm9vY"` cannot.
- No set bits past the final byte. `"Zg=="` decodes `f`; `"Zh=="` does not decode at all.

```ts
base64Parse("Zm 9v");
// UnsecureError MALFORMED: Base64.parse: invalid base64 character " " at index 2.
base64Parse("Zh==");
// UnsecureError MALFORMED: Base64.parse: the last base64 symbol sets bits past the final byte.
base32Parse("MZXW7===");
// UnsecureError MALFORMED: Base32.parse: the last base32 symbol sets bits past the final byte.
base32Parse("mzxw6ytboi");
// UnsecureError MALFORMED: Base32.parse: invalid base32 character "m" at index 0.
```

**Loose normalizes and never throws on shape.** Every character outside the alphabet is dropped — whitespace, `=`, junk, anything non-ASCII. Base64 folds `-_` onto `+/` and accepts either alphabet. A trailing symbol that cannot start a byte is dropped, and bits past the final byte are ignored. Nullish input still throws `INVALID_TYPE`.

```ts
base64Parse("Zm 9v", { loose: true }); // "foo"
base32Parse("mzxw6ytboi", { loose: true }); // "foobar"
```

Use `{ loose: true }` for values a person may have typed or pasted, such as an OTP secret written in groups of four.

## Text handling

A `Uint8Array` input is the **encoded text's** bytes, read one character per byte. A byte at or above 0x80 is therefore a character no alphabet carries: strict rejects it, loose drops it. A leading `EF BB BF` is three such characters, not a BOM to skip.

On the way out, a decoded U+FEFF is kept, because it is part of the byte string and not a signature. Strict decoding to a string requires the bytes to be valid UTF-8 and throws `MALFORMED` otherwise; loose substitutes U+FFFD. Ask for `{ returnAs: "bytes" }` when the payload is not text.

## Shared encoder and decoder

```ts
import { textEncoder, textDecoder } from "unsecure/utils";

const bytes = textEncoder.encode("hello");
const text = textDecoder.decode(bytes);
```

Two module-level instances, so you do not allocate a `TextEncoder` per call. `textDecoder` is created with `ignoreBOM: true`, so it keeps a leading U+FEFF.

## Errors

| Cause                                                                      | `code`         |
| -------------------------------------------------------------------------- | -------------- |
| A value that is not text or bytes, or is nullish                           | `INVALID_TYPE` |
| Text that is not the canonical encoding, or bytes that are not valid UTF-8 | `MALFORMED`    |
| A base32 `alphabet` the codec cannot use                                   | `OUT_OF_RANGE` |

Malformed errors name the character and where it appeared, so you can put the message straight into a 400 response body.

## Note: which backend runs underneath

Encoding prefers Node's `Buffer` when it is there, then the TC39 `Uint8Array.toBase64` and `toHex` methods, then a manual fallback.

Decoding settles the contract in JavaScript first and hands a backend only canonical, fully padded, standard-alphabet text to bulk-decode. Native `fromBase64`'s own strict mode is deliberately never used: it enforces a different contract, with padding mandatory and whitespace fatal, and the result must not depend on which runtime is underneath. Same bytes, same error, everywhere.
