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,111 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { encode } from "./encoder.js";
import { tryEncodeFloat16 } from "./half-float.js";
const hex = (b: Uint8Array): string =>
Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
describe("encoder — bigint range branches", () => {
it("encodes -1n as major-1 minor=0", { timeout: 5000 }, () => {
// -1n is in [-u64, -1]: encoded as major 1, value = -(-1) - 1 = 0
expect(hex(encode(-1n))).toBe("20");
});
it("encodes -100n via major-1", { timeout: 5000 }, () => {
// -100n encodes as major 1 minor=24 0x63 (= -1 - 99 = -100)
expect(hex(encode(-100n))).toBe("3863");
});
it("encodes negative on the u64 boundary", { timeout: 5000 }, () => {
// -2^64 in range? value >= -0x10000000000000000n is true for value=-2^64
// -value - 1n = 2^64 - 1 = 0xffffffffffffffff fits in 8 bytes
expect(hex(encode(-(2n ** 64n)))).toBe("3bffffffffffffffff");
});
});
describe("encoder — half-float edge branches", () => {
it("subnormal reconstruction mismatch returns undefined", {
timeout: 5000,
}, () => {
// 1.5 * 2^-24 — mant rounds to 2, reconstructed = 2^-23 ≠ original
expect(tryEncodeFloat16(1.5 * 2 ** -24)).toBeUndefined();
});
it("returns undefined when rollup overflows exponent", {
timeout: 5000,
}, () => {
// Value such that mantissa rounds up to 1024, pushing biasedExp from
// 30 (0x1e) to 31 (0x1f) — overflow → undefined.
// 65520 has mantissa-bits 1023.5 which rounds to 1024.
expect(tryEncodeFloat16(65520)).toBeUndefined();
});
it("returns undefined when normal-path reconstruction mismatch", {
timeout: 5000,
}, () => {
// 1.0001 — fraction rounds to a mantissa whose reconstructed value
// differs from the input by a tiny amount.
expect(tryEncodeFloat16(1.0001)).toBeUndefined();
});
});
describe("encoder — Map with Tagged keys", () => {
it("deterministic mode sorts mixed-type keys by canonical bytes", {
timeout: 5000,
}, () => {
// Keys: "b" (62), 1 (01), 0xff (18ff)
const m = new Map<unknown, number>([
["b", 1],
[1, 2],
[255, 3],
]);
// Canonical-byte sort: 01 < 18ff < 6162. Expected order: 1, 255, "b"
const out = hex(encode(m, { deterministic: true }));
expect(out.startsWith("a3")).toBe(true);
// Body: 0102 18ff03 616201
expect(out).toBe("a3010218ff036162 01".replace(/\s/g, ""));
});
});
describe("encoder — depth boundary", () => {
it("encodes at exact maxDepth without throw", { timeout: 5000 }, () => {
let nested: unknown = 1;
for (let i = 0; i < 5; i++) nested = [nested];
// maxDepth=5 should accept depth-5; depth-6 throws
expect(() => encode(nested, { maxDepth: 5 })).not.toThrow();
});
});
describe("encoder — depth on bigint outside u64", () => {
it("encodes large positive bignum via tag 2", { timeout: 5000 }, () => {
// 2^128 → tag 2 + 16-byte payload
expect(hex(encode(2n ** 128n)).startsWith("c25")).toBe(true);
});
});
describe("encoder — float branch coverage", () => {
it("deterministic mode encodes NaN via float32 (NaN-NaN equality path)", {
timeout: 5000,
}, () => {
const out = encode(Number.NaN, { deterministic: true });
expect(out[0]).toBe(0xfa); // float32
});
it("preferFloat16=true falls through to float32 when f16 not exact", {
timeout: 5000,
}, () => {
// 1 + 2^-23 — float32-exact; f16 cannot represent the fractional bit.
const value = 1 + 2 ** -23;
const out = encode(value, { preferFloat16: true });
expect(out[0]).toBe(0xfa); // float32
expect(out.length).toBe(5);
});
it("default float path emits float64 when not exact in float32", {
timeout: 5000,
}, () => {
const out = encode(0.1);
expect(out[0]).toBe(0xfb); // float64
expect(out.length).toBe(9);
});
});

View File

@@ -0,0 +1,89 @@
# `encoder`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - half-float — Encodes a JavaScript `number` as IEEE……
- [half-float](./half-float.md) — Encodes a JavaScript `number` as IEEE 754 binary16 (CBOR major 7 / value 25) when the round-trip is exact, and otherwis…
- half-float — Encodes a JavaScript `number` as IEEE……
- [half-float](./half-float.md) — Encodes a JavaScript `number` as IEEE 754 binary16 (CBOR major 7 / value 25) when the round-trip is exact, and otherwis…
- half-float — Encodes a JavaScript `number` as IEEE……
- [half-float](./half-float.md) — Encodes a JavaScript `number` as IEEE 754 binary16 (CBOR major 7 / value 25) when the round-trip is exact, and otherwis…
- half-float — Encodes a JavaScript `number` as IEEE…
- [half-float](./half-float.md) — Encodes a JavaScript `number` as IEEE 754 binary16 (CBOR major 7 / value 25) when the round-trip is exact, and otherwis…
- [half-float](./half-float.md) — Encodes a JavaScript `number` as IEEE 754 binary16 (CBOR major 7 / value 25) when the round-trip is exact, and otherwis…
## Purpose
The CBOR encoder serialises an arbitrary JavaScript value into RFC 8949 binary
form. It supports every CBOR major type (0–7) and applies preferred
serialisation rules — shortest-form integers always-on, shortest-exact float
width by default. It also exposes a deterministic-encoding mode (RFC 8949
§4.2) for callers that need byte-stable output across runs (e.g. signature
generation, content addressing).
## Features
- All seven major types: unsigned, negative, byte string, text string, array,
map, tag, and major-7 (simple values + floats).
- Shortest-form integers per RFC 8949 §4.2.1 — always-on, regardless of mode.
- Shortest-exact float selection among `{float16 (opt-in), float32, float64}`.
- Deterministic mode: sorted map keys (lexicographic over canonical encoded
key bytes), no indefinite-length items, no half-float, rejects `undefined`.
- BigInt support: ranges within `±u64` use major 0/1; outside that range, tag
2 (positive) or tag 3 (negative) bignum byte-string.
- `Tagged<T>` round-trip — encode emits tag-then-inner per §3.4.
- `Map<K,V>` and plain object literals both serialise to major 5 maps.
- Configurable `maxDepth` (default 64) and `maxItems` (default 1_000_000)
guard against pathological inputs.
## Usage: Basic Example
```ts
import { encode } from "@suaveplan/cbor/encoder";
const bytes = encode({ a: 1, b: [true, "two"] });
```
## Usage: Advanced Example
```ts
import { encode } from "@suaveplan/cbor/encoder";
import { Tagged } from "@suaveplan/cbor/tags";
// Deterministic encoding for signing
const claims = new Map<number, unknown>([
[1, "issuer"],
[4, new Tagged(1, 1700000000)], // exp claim with epoch tag
]);
const signedBytes = encode(claims, { deterministic: true });
```
## API Reference
- `encode(value: unknown, options?: EncoderOptions): Uint8Array`
- `EncoderOptions`:
- `deterministic?: boolean` — default `false`. Enforces RFC 8949 §4.2.
- `preferFloat16?: boolean` — default `false`. Try binary16 when exact.
Ignored when `deterministic` is `true`.
- `maxDepth?: number` — default 64. Throws `CBOR.NESTED_TOO_DEEP` on overflow.
- `maxItems?: number` — default 1_000_000. Throws `CBOR.LENGTH_OVERFLOW`.
Throws `CborError` with codes `UNEXPECTED_TYPE` (functions, symbols),
`NON_DETERMINISTIC` (e.g. `undefined` under deterministic), `NESTED_TOO_DEEP`,
and `LENGTH_OVERFLOW`.
## Implementation Notes
The encoder accumulates output as an array of `Uint8Array` chunks and
concatenates at the end. This avoids the O(n²) cost of repeatedly grow-and-
copying a single buffer. Map-key sorting in deterministic mode encodes each
key first (yielding the canonical bytes), then sorts the entry list by
lexicographic byte order before emitting headers and bodies. BigInt encoding
uses native `DataView.setBigUint64` for the in-range case and a hand-rolled
big-endian byte builder for tag 2/3 bignums to avoid pulling in any external
dependency.

View File

@@ -0,0 +1,205 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Tagged } from "../tags/tagged.js";
import { encode } from "./encoder.js";
const hex = (bytes: Uint8Array): string =>
Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
describe("encode — RFC 8949 Appendix A vectors", () => {
it.each([
[0, "00"],
[1, "01"],
[10, "0a"],
[23, "17"],
[24, "1818"],
[25, "1819"],
[100, "1864"],
[1000, "1903e8"],
[1000000, "1a000f4240"],
[1000000000000, "1b000000e8d4a51000"],
[-1, "20"],
[-10, "29"],
[-100, "3863"],
[-1000, "3903e7"],
])("integer %p → %s", { timeout: 5000 }, (input, expected) => {
expect(hex(encode(input))).toBe(expected);
});
it("BigInt within u64 range encodes as major 0", { timeout: 5000 }, () => {
expect(hex(encode(18446744073709551615n))).toBe("1bffffffffffffffff");
});
it("BigInt outside u64 → tag 2 bignum", { timeout: 5000 }, () => {
expect(hex(encode(18446744073709551616n))).toBe(
"c249010000000000000000"
);
});
it("BigInt outside negative u64 → tag 3 bignum", { timeout: 5000 }, () => {
expect(hex(encode(-18446744073709551617n))).toBe(
"c349010000000000000000"
);
});
it.each([
[false, "f4"],
[true, "f5"],
[null, "f6"],
[undefined, "f7"],
])("simple value %p → %s", { timeout: 5000 }, (input, expected) => {
expect(hex(encode(input))).toBe(expected);
});
it("empty byte string", { timeout: 5000 }, () => {
expect(hex(encode(new Uint8Array()))).toBe("40");
});
it("byte string [0x01,0x02,0x03,0x04]", { timeout: 5000 }, () => {
expect(hex(encode(new Uint8Array([1, 2, 3, 4])))).toBe("4401020304");
});
it("empty string", { timeout: 5000 }, () => {
expect(hex(encode(""))).toBe("60");
});
it("'a' → 6161", { timeout: 5000 }, () => {
expect(hex(encode("a"))).toBe("6161");
});
it("'IETF'", { timeout: 5000 }, () => {
expect(hex(encode("IETF"))).toBe("6449455446");
});
it("multibyte UTF-8 'ü'", { timeout: 5000 }, () => {
expect(hex(encode("ü"))).toBe("62c3bc");
});
it("empty array", { timeout: 5000 }, () => {
expect(hex(encode([]))).toBe("80");
});
it("array [1,2,3]", { timeout: 5000 }, () => {
expect(hex(encode([1, 2, 3]))).toBe("83010203");
});
it("nested array [1,[2,3],[4,5]]", { timeout: 5000 }, () => {
expect(hex(encode([1, [2, 3], [4, 5]]))).toBe("8301820203820405");
});
it("empty map", { timeout: 5000 }, () => {
expect(hex(encode(new Map()))).toBe("a0");
});
it("map {1:2,3:4}", { timeout: 5000 }, () => {
const m = new Map<number, number>([
[1, 2],
[3, 4],
]);
expect(hex(encode(m))).toBe("a201020304");
});
it("Tagged(0, '2013-03-21T20:04:00Z')", { timeout: 5000 }, () => {
const tagged = new Tagged(0, "2013-03-21T20:04:00Z");
expect(hex(encode(tagged))).toBe(
"c074323031332d30332d32315432303a30343a30305a"
);
});
it("Tagged(1, 1363896240) epoch time", { timeout: 5000 }, () => {
expect(hex(encode(new Tagged(1, 1363896240)))).toBe("c11a514b67b0");
});
});
describe("encode — deterministic mode", () => {
it("sorts map keys lexicographically by canonical bytes", {
timeout: 5000,
}, () => {
const m = new Map<number, number>([
[10, 1],
[1, 2],
[100, 3],
]);
const out = encode(m, { deterministic: true });
// Keys should be 01 (1), 0a (10), 1864 (100)
expect(hex(out).startsWith("a3")).toBe(true);
expect(hex(out)).toBe("a301020a01186403");
});
it("rejects undefined under deterministic", { timeout: 5000 }, () => {
expect(() => encode(undefined, { deterministic: true })).toThrow(
/CBOR\.NON_DETERMINISTIC/
);
});
it("uses shortest-exact float (no float16) under deterministic", {
timeout: 5000,
}, () => {
// 1.5 fits exactly in float32; deterministic mode disables float16 but
// keeps shortest-exact among {float32, float64}.
const out = encode(1.5, { deterministic: true });
expect(out[0]).toBe(0xfa); // major 7 / minor 26 = float32
expect(out.length).toBe(5);
});
it("uses float64 when value not representable in float32 (deterministic)", {
timeout: 5000,
}, () => {
const out = encode(0.1, { deterministic: true });
expect(out[0]).toBe(0xfb);
expect(out.length).toBe(9);
});
});
describe("encode — bounds", () => {
it("throws NESTED_TOO_DEEP past maxDepth", { timeout: 5000 }, () => {
let nested: unknown = 1;
for (let i = 0; i < 65; i++) nested = [nested];
expect(() => encode(nested, { maxDepth: 64 })).toThrow(
/NESTED_TOO_DEEP/
);
});
it("throws LENGTH_OVERFLOW past maxItems", { timeout: 5000 }, () => {
const big = Array.from({ length: 11 }, (_, i) => i);
expect(() => encode(big, { maxItems: 10 })).toThrow(/LENGTH_OVERFLOW/);
});
it("rejects function", { timeout: 5000 }, () => {
expect(() => encode(() => undefined)).toThrow(/UNEXPECTED_TYPE/);
});
it("rejects symbol", { timeout: 5000 }, () => {
expect(() => encode(Symbol("x"))).toThrow(/UNEXPECTED_TYPE/);
});
});
describe("encode — float16", () => {
it("preferFloat16 emits 2-byte float for exactly-representable", {
timeout: 5000,
}, () => {
const out = encode(1.5, { preferFloat16: true });
expect(out[0]).toBe(0xf9);
expect(out.length).toBe(3);
});
it("falls back to float32 when float16 not exact", {
timeout: 5000,
}, () => {
// 0.1 is not representable in any binary float exactly, but float32 is closer than float16
const out = encode(0.1, { preferFloat16: true });
// Should select float64 because no narrower form is exact
expect(out[0]).toBe(0xfb);
});
});
describe("encode — object literal", () => {
it("encodes plain object as map", { timeout: 5000 }, () => {
const out = encode({ a: 1, b: 2 });
expect(hex(out)).toBe("a26161016162 02".replace(/\s/g, ""));
});
it("deterministic-sorts object keys", { timeout: 5000 }, () => {
const out = encode({ b: 2, a: 1 }, { deterministic: true });
expect(hex(out)).toBe("a26161016162 02".replace(/\s/g, ""));
});
});

View File

@@ -0,0 +1,349 @@
import { CBOR_CODES, cborError } from "../errors/error-codes.js";
import { Tagged } from "../tags/tagged.js";
import {
DEFAULT_ENCODER_OPTIONS,
type EncoderOptions,
} from "./encoder.types.js";
import { selectFloatWidth, tryEncodeFloat16 } from "./half-float.js";
const TEXT_ENCODER = new TextEncoder();
/** Major-type prefixes (top 3 bits). */
const MT_UNSIGNED = 0x00;
const MT_NEGATIVE = 0x20;
const MT_BYTES = 0x40;
const MT_TEXT = 0x60;
const MT_ARRAY = 0x80;
const MT_MAP = 0xa0;
const MT_TAG = 0xc0;
const MT_SIMPLE = 0xe0;
interface EncodeContext {
readonly options: Required<EncoderOptions>;
depth: number;
items: number;
}
/**
* Encode `value` to a CBOR byte sequence per RFC 8949. Throws `CborError` for
* unsupported values (e.g. `function`, `Symbol`), depth-overflow, and
* deterministic-mode violations.
*/
export function encode(
value: unknown,
options: EncoderOptions = {}
): Uint8Array {
const ctx: EncodeContext = {
options: { ...DEFAULT_ENCODER_OPTIONS, ...options },
depth: 0,
items: 0,
};
const chunks: Uint8Array[] = [];
encodeValue(value, ctx, chunks);
return concat(chunks);
}
function encodeValue(
value: unknown,
ctx: EncodeContext,
out: Uint8Array[]
): void {
ctx.items += 1;
if (ctx.items > ctx.options.maxItems) {
throw cborError(
CBOR_CODES.LENGTH_OVERFLOW,
`encoded item count exceeds ${ctx.options.maxItems}`
);
}
if (ctx.depth > ctx.options.maxDepth) {
throw cborError(
CBOR_CODES.NESTED_TOO_DEEP,
`encoded depth exceeds ${ctx.options.maxDepth}`
);
}
if (value === null) {
out.push(new Uint8Array([0xf6]));
return;
}
if (value === undefined) {
if (ctx.options.deterministic) {
throw cborError(
CBOR_CODES.NON_DETERMINISTIC,
"deterministic mode forbids `undefined`"
);
}
out.push(new Uint8Array([0xf7]));
return;
}
if (typeof value === "boolean") {
out.push(new Uint8Array([value ? 0xf5 : 0xf4]));
return;
}
if (typeof value === "number") {
encodeNumber(value, ctx, out);
return;
}
if (typeof value === "bigint") {
encodeBigInt(value, out);
return;
}
if (typeof value === "string") {
encodeString(value, out);
return;
}
if (value instanceof Uint8Array) {
encodeBytes(value, out);
return;
}
if (value instanceof Tagged) {
encodeTagged(value, ctx, out);
return;
}
if (Array.isArray(value)) {
encodeArray(value, ctx, out);
return;
}
if (value instanceof Map) {
encodeMap(value, ctx, out);
return;
}
if (typeof value === "object") {
encodeObject(value as Record<string, unknown>, ctx, out);
return;
}
throw cborError(
CBOR_CODES.UNEXPECTED_TYPE,
`cannot encode value of type ${typeof value}`
);
}
function encodeHead(
major: number,
n: number | bigint,
out: Uint8Array[]
): void {
const v = typeof n === "bigint" ? n : BigInt(n);
if (v < 24n) {
out.push(new Uint8Array([major | Number(v)]));
return;
}
if (v < 0x100n) {
out.push(new Uint8Array([major | 24, Number(v)]));
return;
}
if (v < 0x10000n) {
const num = Number(v);
out.push(new Uint8Array([major | 25, (num >> 8) & 0xff, num & 0xff]));
return;
}
if (v < 0x100000000n) {
const num = Number(v);
out.push(
new Uint8Array([
major | 26,
(num >> 24) & 0xff,
(num >> 16) & 0xff,
(num >> 8) & 0xff,
num & 0xff,
])
);
return;
}
const buf = new Uint8Array(9);
buf[0] = major | 27;
const view = new DataView(buf.buffer);
view.setBigUint64(1, v, false);
out.push(buf);
}
function encodeNumber(
value: number,
ctx: EncodeContext,
out: Uint8Array[]
): void {
if (
Number.isInteger(value) &&
value >= Number.MIN_SAFE_INTEGER &&
value <= Number.MAX_SAFE_INTEGER
) {
if (value >= 0) {
encodeHead(MT_UNSIGNED, value, out);
} else {
encodeHead(MT_NEGATIVE, -value - 1, out);
}
return;
}
// Float — pick narrowest exact width
if (ctx.options.deterministic) {
// Deterministic: half-float disabled per the proposal's design choice.
// Pick the shortest exact width among {float32, float64} so the encoding
// is still deterministic AND minimal.
const f32buf = new ArrayBuffer(4);
const view32 = new DataView(f32buf);
view32.setFloat32(0, value, false);
const round32 = view32.getFloat32(0, false);
const exact32 =
round32 === value || (Number.isNaN(value) && Number.isNaN(round32));
if (exact32) {
out.push(new Uint8Array([MT_SIMPLE | 26]));
out.push(new Uint8Array(f32buf));
return;
}
const f64buf = new ArrayBuffer(8);
new DataView(f64buf).setFloat64(0, value, false);
out.push(new Uint8Array([MT_SIMPLE | 27]));
out.push(new Uint8Array(f64buf));
return;
}
if (ctx.options.preferFloat16) {
const f16 = tryEncodeFloat16(value);
if (f16) {
out.push(new Uint8Array([MT_SIMPLE | 25]));
out.push(f16);
return;
}
}
const { width, bytes } = selectFloatWidth(value);
const head = width === 4 ? MT_SIMPLE | 26 : MT_SIMPLE | 27;
out.push(new Uint8Array([head]));
out.push(bytes);
}
function encodeBigInt(value: bigint, out: Uint8Array[]): void {
if (value >= 0n && value <= 0xffffffffffffffffn) {
encodeHead(MT_UNSIGNED, value, out);
return;
}
if (value < 0n && value >= -0x10000000000000000n) {
encodeHead(MT_NEGATIVE, -value - 1n, out);
return;
}
// Big-num via tag 2 / 3
const tag = value >= 0n ? 2 : 3;
const positive = value >= 0n ? value : -value - 1n;
const bytes = bigIntToBytes(positive);
encodeHead(MT_TAG, tag, out);
encodeHead(MT_BYTES, bytes.length, out);
out.push(bytes);
}
function bigIntToBytes(v: bigint): Uint8Array {
// Caller never passes 0n: encodeBigInt routes 0n through the unsigned
// major-0 fast path, and the bignum path is only reached for values
// strictly outside the ±u64 range.
let n = v;
const bytes: number[] = [];
while (n > 0n) {
bytes.push(Number(n & 0xffn));
n >>= 8n;
}
bytes.reverse();
return new Uint8Array(bytes);
}
function encodeString(value: string, out: Uint8Array[]): void {
const bytes = TEXT_ENCODER.encode(value);
encodeHead(MT_TEXT, bytes.length, out);
out.push(bytes);
}
function encodeBytes(value: Uint8Array, out: Uint8Array[]): void {
encodeHead(MT_BYTES, value.length, out);
out.push(value);
}
function encodeArray(
value: readonly unknown[],
ctx: EncodeContext,
out: Uint8Array[]
): void {
encodeHead(MT_ARRAY, value.length, out);
ctx.depth += 1;
for (const item of value) encodeValue(item, ctx, out);
ctx.depth -= 1;
}
function encodeMap(
value: Map<unknown, unknown>,
ctx: EncodeContext,
out: Uint8Array[]
): void {
const entries: Array<[Uint8Array, Uint8Array]> = [];
for (const [k, v] of value) {
const keyChunks: Uint8Array[] = [];
const valChunks: Uint8Array[] = [];
ctx.depth += 1;
encodeValue(k, ctx, keyChunks);
encodeValue(v, ctx, valChunks);
ctx.depth -= 1;
entries.push([concat(keyChunks), concat(valChunks)]);
}
if (ctx.options.deterministic)
entries.sort((a, b) => compareBytes(a[0], b[0]));
encodeHead(MT_MAP, entries.length, out);
for (const [k, v] of entries) {
out.push(k);
out.push(v);
}
}
function encodeObject(
value: Record<string, unknown>,
ctx: EncodeContext,
out: Uint8Array[]
): void {
const keys = Object.keys(value);
const entries: Array<[Uint8Array, Uint8Array]> = [];
for (const k of keys) {
const keyChunks: Uint8Array[] = [];
const valChunks: Uint8Array[] = [];
ctx.depth += 1;
encodeValue(k, ctx, keyChunks);
encodeValue(value[k], ctx, valChunks);
ctx.depth -= 1;
entries.push([concat(keyChunks), concat(valChunks)]);
}
if (ctx.options.deterministic)
entries.sort((a, b) => compareBytes(a[0], b[0]));
encodeHead(MT_MAP, entries.length, out);
for (const [k, v] of entries) {
out.push(k);
out.push(v);
}
}
function encodeTagged(
value: Tagged,
ctx: EncodeContext,
out: Uint8Array[]
): void {
encodeHead(MT_TAG, value.tag, out);
ctx.depth += 1;
encodeValue(value.value, ctx, out);
ctx.depth -= 1;
}
function compareBytes(a: Uint8Array, b: Uint8Array): number {
// Canonical CBOR encodings are prefix-free (each major type's head byte
// pins both type and length), and Map semantics rule out duplicate keys,
// so two distinct CBOR-encoded keys always differ at some byte index in
// [0, min(a.length, b.length)). Returning at the first differing index is
// both correct AND total — every reachable input exits via the inner
// `return`.
const len = Math.min(a.length, b.length);
let i = 0;
while (i < len && a[i] === b[i]) i++;
return (a[i] as number) - (b[i] as number);
}
function concat(chunks: readonly Uint8Array[]): Uint8Array {
let total = 0;
for (const c of chunks) total += c.length;
const out = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
out.set(c, off);
off += c.length;
}
return out;
}

View File

@@ -0,0 +1,40 @@
/**
* Caller-supplied encoder options. Every field is optional; defaults match
* RFC 8949 §4.1 preferred-serialization rules.
*/
export interface EncoderOptions {
/**
* Enforce RFC 8949 §4.2 deterministic encoding. Implies sorted map keys
* (lexicographic over canonical encoded key bytes), shortest-form integers
* (always-on regardless), no indefinite-length items, and no half-float
* shortcuts. When `true`, attempts to emit indefinite items throw
* `CBOR.NON_DETERMINISTIC`.
*/
deterministic?: boolean;
/**
* Try to encode floats as binary16 when round-trip is exact. Off by default;
* when `deterministic` is `true`, this flag is ignored (always off).
*/
preferFloat16?: boolean;
/**
* Maximum nesting depth before the encoder throws `CBOR.NESTED_TOO_DEEP`.
* Default: 64. Guards against stack overflow on adversarial inputs.
*/
maxDepth?: number;
/**
* Maximum total item count emitted before the encoder throws
* `CBOR.LENGTH_OVERFLOW`. Default: 1_000_000.
*/
maxItems?: number;
}
/** Default encoder options applied at the boundary. */
export const DEFAULT_ENCODER_OPTIONS: Required<EncoderOptions> = {
deterministic: false,
preferFloat16: false,
maxDepth: 64,
maxItems: 1_000_000,
};

View File

@@ -0,0 +1,58 @@
# `half-float` (encoder side)
## Purpose
Encodes a JavaScript `number` as IEEE 754 binary16 (CBOR major 7 / value 25)
when the round-trip is exact, and otherwise indicates non-representability so
the caller can pick a wider format. Also exports `selectFloatWidth`, which
chooses between float32 and float64 based on exact-representability — the
encoder's deterministic path uses this to skip half-float without giving up
shortest-exact behaviour at the float32 / float64 boundary.
## Features
- Full IEEE 754 binary16 coverage: ±0, ±Infinity, NaN, normal values,
subnormals, smallest-normal / largest-normal boundaries.
- `tryEncodeFloat16(value)` returns the 2-byte big-endian sequence on exact
round-trip; returns `undefined` on overflow / underflow / inexact-precision.
- `selectFloatWidth(value)` picks float32 (4 bytes) when `getFloat32` round-
trips the value exactly, otherwise float64 (8 bytes). NaN is treated as
float32-exact since both float32 and float64 represent NaN.
## Usage: Basic Example
```ts
import { tryEncodeFloat16 } from "@suaveplan/cbor/encoder";
const bytes = tryEncodeFloat16(1.5); // Uint8Array(2) [0x3e, 0x00]
```
## Usage: Advanced Example
```ts
import { selectFloatWidth, tryEncodeFloat16 } from "@suaveplan/cbor/encoder";
function pickFloat(value: number): { tag: number; bytes: Uint8Array } {
const f16 = tryEncodeFloat16(value);
if (f16) return { tag: 0xf9, bytes: f16 };
const { width, bytes } = selectFloatWidth(value);
return { tag: width === 4 ? 0xfa : 0xfb, bytes };
}
```
## API Reference
- `tryEncodeFloat16(value: number): Uint8Array | undefined` — exact-only.
- `selectFloatWidth(value: number): { width: 4 | 8; bytes: Uint8Array }`.
- `type FloatWidth = 4 | 8`.
## Implementation Notes
The exact-only contract is critical for deterministic encoding: a lossy
half-float emission would produce different bytes for inputs that look
"close" but differ in the last bit of the mantissa. The implementation
verifies round-trip via reconstruction (`(1 + mant/1024) * 2^(exp-15)` for
normals, `mant * 2^-24` for subnormals) and rejects when the rebuild does
not equal the input under `===`. NaN is hard-coded to `7e00` (the canonical
quiet-NaN form) — RFC 8949 §3.3 explicitly permits losing NaN payload bits,
which JavaScript would do anyway since `Number` collapses payloads.

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { selectFloatWidth, tryEncodeFloat16 } from "./half-float.js";
const hex = (b: Uint8Array | undefined): string =>
b
? Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("")
: "<undef>";
describe("tryEncodeFloat16 — IEEE 754 binary16", () => {
it("encodes +0", { timeout: 5000 }, () => {
expect(hex(tryEncodeFloat16(0))).toBe("0000");
});
it("encodes -0", { timeout: 5000 }, () => {
expect(hex(tryEncodeFloat16(-0))).toBe("8000");
});
it("encodes 1.0", { timeout: 5000 }, () => {
expect(hex(tryEncodeFloat16(1))).toBe("3c00");
});
it("encodes 1.5", { timeout: 5000 }, () => {
expect(hex(tryEncodeFloat16(1.5))).toBe("3e00");
});
it("encodes -1.0", { timeout: 5000 }, () => {
expect(hex(tryEncodeFloat16(-1))).toBe("bc00");
});
it("encodes +Infinity", { timeout: 5000 }, () => {
expect(hex(tryEncodeFloat16(Number.POSITIVE_INFINITY))).toBe("7c00");
});
it("encodes -Infinity", { timeout: 5000 }, () => {
expect(hex(tryEncodeFloat16(Number.NEGATIVE_INFINITY))).toBe("fc00");
});
it("encodes NaN", { timeout: 5000 }, () => {
expect(hex(tryEncodeFloat16(Number.NaN))).toBe("7e00");
});
it("encodes smallest positive subnormal 2^-24", { timeout: 5000 }, () => {
expect(hex(tryEncodeFloat16(2 ** -24))).toBe("0001");
});
it("encodes largest subnormal", { timeout: 5000 }, () => {
expect(hex(tryEncodeFloat16((2 ** 10 - 1) * 2 ** -24))).toBe("03ff");
});
it("returns undefined for value too large for f16", {
timeout: 5000,
}, () => {
expect(tryEncodeFloat16(70000)).toBeUndefined();
});
it("returns undefined for non-exact float (0.1)", { timeout: 5000 }, () => {
expect(tryEncodeFloat16(0.1)).toBeUndefined();
});
it("returns undefined for too-small subnormal underflow", {
timeout: 5000,
}, () => {
// Smaller than smallest subnormal — should return undefined
expect(tryEncodeFloat16(2 ** -30)).toBeUndefined();
});
});
describe("selectFloatWidth", () => {
it("returns float32 for exactly representable values", {
timeout: 5000,
}, () => {
const r = selectFloatWidth(1.5);
expect(r.width).toBe(4);
expect(r.bytes.length).toBe(4);
});
it("returns float64 for non-float32-exact values", {
timeout: 5000,
}, () => {
const r = selectFloatWidth(0.1);
expect(r.width).toBe(8);
expect(r.bytes.length).toBe(8);
});
it("returns float32 for NaN (NaN-NaN equality path)", {
timeout: 5000,
}, () => {
const r = selectFloatWidth(Number.NaN);
expect(r.width).toBe(4);
});
});

View File

@@ -0,0 +1,86 @@
/**
* IEEE-754 binary16 (half-precision) encoder for CBOR major type 7 / value 25.
*
* Reference: RFC 8949 §3.3 + Appendix D and §A test vectors. The encoder takes
* a JavaScript `number` and either returns the 2-byte big-endian binary16
* representation OR `undefined` when the value cannot be represented exactly
* (loses precision relative to the input float64). Callers fall back to
* float32 / float64 in the latter case so deterministic encoding never
* silently degrades precision.
*/
const FLOAT16_BIAS = 15;
const FLOAT16_MANT_BITS = 10;
const _FLOAT16_EXP_BITS = 5;
/**
* Try to encode `value` as binary16. Returns the 2 big-endian bytes when the
* round-trip is exact; returns `undefined` otherwise so the caller can pick a
* wider format.
*/
export function tryEncodeFloat16(value: number): Uint8Array | undefined {
if (Number.isNaN(value)) return new Uint8Array([0x7e, 0x00]);
if (value === Number.POSITIVE_INFINITY) return new Uint8Array([0x7c, 0x00]);
if (value === Number.NEGATIVE_INFINITY) return new Uint8Array([0xfc, 0x00]);
if (value === 0) {
const sign = Object.is(value, -0) ? 0x80 : 0x00;
return new Uint8Array([sign, 0x00]);
}
const sign = value < 0 ? 1 : 0;
const abs = Math.abs(value);
const exp = Math.floor(Math.log2(abs));
const biasedExp = exp + FLOAT16_BIAS;
if (biasedExp >= 0x1f) return undefined; // overflow → caller uses wider format
let mantissaBits: number;
let storedExp: number;
if (biasedExp <= 0) {
// Subnormal: exp stored as 0; value = mant * 2^-24 with mant ∈ [1, 1023].
const mant = Math.round(abs * 2 ** 24);
if (mant <= 0 || mant >= 1024) return undefined;
const reconstructed = mant * 2 ** -24;
if (reconstructed !== abs) return undefined;
storedExp = 0;
mantissaBits = mant;
} else {
const fraction = abs / 2 ** exp - 1;
mantissaBits = Math.round(fraction * 2 ** FLOAT16_MANT_BITS);
// When the rounded mantissa hits 2^10, the value would round up into
// the next exponent. Reject — the only abs that round-trips through
// this path is `abs === 2^(exp+1)` which would have been classified
// at exp+1 from the start, so the rollup case never produces a valid
// half-float.
if (mantissaBits === 1 << FLOAT16_MANT_BITS) return undefined;
storedExp = biasedExp;
const reconstructed =
(1 + mantissaBits / 2 ** FLOAT16_MANT_BITS) * 2 ** exp;
if (reconstructed !== abs) return undefined;
}
const bits = (sign << 15) | (storedExp << FLOAT16_MANT_BITS) | mantissaBits;
return new Uint8Array([(bits >> 8) & 0xff, bits & 0xff]);
}
/** Float-bit-width selector used by the encoder hot path. */
export type FloatWidth = 4 | 8;
/**
* Pick the narrowest exact float width for `value` among `{float32, float64}`.
* Float16 is excluded — opt-in via `tryEncodeFloat16` from the call site so
* deterministic-mode behaviour stays predictable.
*/
export function selectFloatWidth(value: number): {
width: FloatWidth;
bytes: Uint8Array;
} {
const f32buf = new ArrayBuffer(4);
const f32view = new DataView(f32buf);
f32view.setFloat32(0, value, false);
if (
f32view.getFloat32(0, false) === value ||
(Number.isNaN(value) && Number.isNaN(f32view.getFloat32(0, false)))
) {
return { width: 4, bytes: new Uint8Array(f32buf) };
}
const f64buf = new ArrayBuffer(8);
new DataView(f64buf).setFloat64(0, value, false);
return { width: 8, bytes: new Uint8Array(f64buf) };
}

View File

@@ -0,0 +1,10 @@
export { encode } from "./encoder.js";
export {
DEFAULT_ENCODER_OPTIONS,
type EncoderOptions,
} from "./encoder.types.js";
export {
type FloatWidth,
selectFloatWidth,
tryEncodeFloat16,
} from "./half-float.js";