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,2 @@
export { decodeBinary } from "./msgpack-decode";
export { encodeBinary } from "./msgpack-encode";

View File

@@ -0,0 +1,75 @@
# msgpack-decode
## Purpose
Implements `decodeBinary`, the inverse of `encodeBinary`. Given a MessagePack binary buffer produced by the encoder, it reconstructs the original JavaScript value by first decoding the msgpack wire bytes to an intermediate plain-JS-object representation and then applying the same tag-based reconstruction logic used by `deserialize`.
The two-phase approach (msgpack decode → tag reconstruct) keeps the decoder code minimal and reuses the same registry lookup path as the JSON decoder.
## Features
- Full inverse of `encodeBinary`: all built-in and custom registered types are reconstructed
- `UnsupportedTypeError` thrown on unrecognised `__t` tags (same strict policy as JSON decoder)
- Reuses a single shared `Decoder` instance for zero allocation overhead across calls
- `DeserializeOptions` parameter accepted for API consistency with the JSON codec (signal not yet applied internally, as msgpack decode is synchronous and very fast)
## Usage: Basic Example
```typescript
import { decodeBinary, encodeBinary } from "@suaveplan/serialize";
const original = new Map([["key", new Date()]]);
const bytes = encodeBinary(original);
const restored = decodeBinary(bytes) as Map<string, Date>;
restored instanceof Map; // true
restored.get("key") instanceof Date; // true
```
## Usage: Advanced Example
```typescript
import { decodeBinary, UnsupportedTypeError } from "@suaveplan/serialize";
async function receiveMessage(bytes: Uint8Array) {
try {
const value = decodeBinary(bytes);
return value;
} catch (err) {
if (err instanceof UnsupportedTypeError) {
// The sender used a type tag unknown to this receiver.
// Either register the type or reject the message.
throw new Error(`Unknown type: ${err.context?.tag}`);
}
throw err;
}
}
```
## API Reference
### `decodeBinary(bytes: Uint8Array, opts?: DeserializeOptions): unknown`
Decode a MessagePack buffer to the original JavaScript value.
**Parameters:**
- `bytes` — a `Uint8Array` produced by `encodeBinary`
- `opts` — `DeserializeOptions` (accepted for API parity; signal not currently applied within this synchronous path)
**Returns:** the reconstructed JavaScript value.
**Throws:**
- `UnsupportedTypeError` when a `__t` tag has no registered decoder
- `RangeError` or `TypeError` from `@msgpack/msgpack` if the input bytes are malformed
### `DeserializeOptions`
See `json-decode.md`.
## Implementation Notes
- A single shared `Decoder` instance is reused to avoid per-call setup overhead; the decoder is stateless between calls.
- The `postDecode` function is structurally identical to the inner `decodeValue` used by `json-decode.ts`. Future refactoring could extract it to a shared utility; currently it is kept local to avoid premature abstraction.
- The `opts` parameter is accepted but currently unused because the msgpack decode step is synchronous and does not lend itself to mid-stream cancellation without a streaming API.

View File

@@ -0,0 +1,91 @@
import { Decoder } from "@msgpack/msgpack";
import {
InvalidOptionsError,
MalformedWireError,
SerializationError,
UnsupportedTypeError,
} from "../../errors/serialize-error.js";
import { getSerializeMetrics } from "../../observability/metrics.js";
import { getType } from "../../registry/registry.js";
import type { DeserializeOptions } from "../json/json-decode.js";
const _decoder = new Decoder();
const ALLOWED_DECODE_BINARY_KEYS = new Set(["signal"]);
function validateDecodeBinaryOptions(opts?: DeserializeOptions): void {
if (opts === undefined) return;
for (const key of Object.keys(opts as object)) {
if (!ALLOWED_DECODE_BINARY_KEYS.has(key)) {
throw new InvalidOptionsError(
`unknown decodeBinary option: ${key}`
);
}
}
}
function postDecode(val: unknown): unknown {
if (val === null || val === undefined) return val;
if (typeof val !== "object") return val;
if (Array.isArray(val)) {
return val.map((item) => postDecode(item));
}
const obj = val as Record<string, unknown>;
if ("__t" in obj) {
if (typeof obj["__t"] !== "string") {
throw new MalformedWireError("non-string-__t");
}
const tag = obj["__t"];
const def = getType(tag);
if (def === undefined) {
throw new UnsupportedTypeError(tag);
}
const decodedPayload = postDecode(obj["v"]);
return def.decode(decodedPayload);
}
const result: Record<string, unknown> = {};
for (const key of Object.keys(obj)) {
result[key] = postDecode(obj[key]);
}
return result;
}
/**
* Decode a MessagePack binary buffer produced by `encodeBinary` back to the
* original JavaScript value. Malformed bytes throw `MalformedWireError`;
* unknown `__t` tags throw `UnsupportedTypeError`.
*/
export function decodeBinary<T = unknown>(
bytes: Uint8Array,
opts?: DeserializeOptions
): T {
validateDecodeBinaryOptions(opts);
const m = getSerializeMetrics();
const start = Date.now();
let intermediate: unknown;
try {
intermediate = _decoder.decode(bytes);
} catch (cause) {
const err = new MalformedWireError("msgpack.decode", undefined, cause);
m.errorTotal.add(1, { code: err.code, format: "msgpack" });
throw err;
}
try {
const result = postDecode(intermediate);
m.decodeBytes.record(bytes.byteLength, { format: "msgpack" });
m.decodeDuration.record(Date.now() - start, { format: "msgpack" });
return result as T;
} catch (err) {
const code =
err instanceof SerializationError
? err.code
: "SERIALIZE.CODEC.UNKNOWN";
m.errorTotal.add(1, { code, format: "msgpack" });
throw err;
}
}

View File

@@ -0,0 +1,78 @@
# msgpack-encode
## Purpose
Implements `encodeBinary`, the MessagePack counterpart to `serialize`. It converts any JavaScript value to a compact binary `Uint8Array` using the same type-registry tag scheme as the JSON encoder. By pre-processing the input into plain msgpack-compatible intermediate values before handing off to `@msgpack/msgpack`, the codec avoids the complexity of a custom extension codec while still preserving all built-in and custom registered types.
Object keys are sorted canonically before encoding, so two structurally identical objects always produce bit-identical output regardless of property insertion order.
## Features
- Produces a compact binary wire format via `@msgpack/msgpack`
- Same type-registry integration as the JSON codec: all built-in and custom types round-trip faithfully
- Canonical key ordering for deterministic binary output
- `maxDepth`, `maxSize`, and `AbortSignal` options parallel to the JSON encoder
- Cycle detection via `WeakSet` — throws `CircularReferenceError` on cycles
- Throws `MaxDepthExceededError` and `MaxSizeExceededError` on limit violations
## Usage: Basic Example
```typescript
import { decodeBinary, encodeBinary } from "@suaveplan/serialize";
const payload = { created: new Date(), ids: new Set([1, 2, 3]) };
const bytes = encodeBinary(payload);
// bytes is a compact Uint8Array — smaller than JSON for numeric-heavy data
const restored = decodeBinary(bytes);
```
## Usage: Advanced Example
```typescript
import { encodeBinary } from "@suaveplan/serialize";
const controller = new AbortController();
const bytes = encodeBinary(largeDataset, {
maxDepth: 30,
maxSize: 1_000_000, // 1 MB
signal: controller.signal,
});
// Send over WebSocket, IndexedDB, etc.
socket.send(bytes);
```
## API Reference
### `encodeBinary(value: unknown, opts?: SerializeOptions): Uint8Array`
Encodes `value` to a MessagePack binary buffer.
**Parameters:**
- `value` — any JavaScript value; functions and symbols are silently dropped
- `opts.maxDepth` — maximum recursion depth (default: `100`)
- `opts.maxSize` — maximum byte length of the binary output; checked after encoding
- `opts.signal` — `AbortSignal`; checked once before encoding begins
**Returns:** a `Uint8Array` containing the MessagePack payload.
**Throws:**
- `CircularReferenceError` on cycles
- `MaxDepthExceededError` when nesting exceeds `maxDepth`
- `MaxSizeExceededError` when output exceeds `maxSize` bytes
- The `AbortSignal`'s reason if aborted
### `SerializeOptions`
See `json-encode.md` — the same options interface is shared.
## Implementation Notes
- A single shared `Encoder` instance is reused across calls to amortise initialisation overhead; the encoder is stateless between calls.
- The `preEncode` helper mirrors the logic in `json-encode.ts` but emits plain objects/arrays instead of a JSON string, avoiding a second parse step.
- `AbortSignal` is checked once at the call boundary rather than every 256 nodes because msgpack encoding is typically fast and does not support mid-stream interruption without a custom streaming approach.
- Canonical key sorting uses the default `Array.prototype.sort` comparator (lexicographic UTF-16 code units), matching the sort used by many canonical JSON implementations.

View File

@@ -0,0 +1,81 @@
/**
* Spec-conformance test for SZ-06 MessagePack profile: `encodeBinary`
* MUST produce bit-identical output for identical inputs (object keys
* canonicalised via sorted insertion). The original suite asserts
* structural equivalence after a round-trip but not bit-equality of the
* encoded buffer.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import { InvalidOptionsError } from "../../errors/serialize-error.js";
import { decodeBinary } from "./msgpack-decode.js";
import { encodeBinary } from "./msgpack-encode.js";
function toHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
describe("msgpack options validation", () => {
it("encodeBinary throws InvalidOptionsError for unknown option key", {
timeout: 5000,
}, () => {
expect(() => encodeBinary("hello", { badKey: true } as never)).toThrow(
InvalidOptionsError
);
});
it("decodeBinary throws InvalidOptionsError for unknown option key", {
timeout: 5000,
}, () => {
const bytes = encodeBinary("hello");
expect(() => decodeBinary(bytes, { badKey: true } as never)).toThrow(
InvalidOptionsError
);
});
it("decodeBinary accepts valid signal option", { timeout: 5000 }, () => {
const bytes = encodeBinary("hello");
const ac = new AbortController();
expect(decodeBinary(bytes, { signal: ac.signal })).toBe("hello");
});
});
describe("SZ-06 — encodeBinary produces byte-identical output", () => {
it("two encodes of the same structured input produce identical bytes", {
timeout: 5000,
}, () => {
const input = {
name: "test",
count: 42,
flags: [true, false, true],
meta: { created: new Date("2024-01-01T00:00:00.000Z") },
};
const a = encodeBinary(input);
const b = encodeBinary(input);
expect(toHex(a)).toBe(toHex(b));
});
it("encoding is insensitive to object-key insertion order", {
timeout: 5000,
}, () => {
// Spec MUST: "field ordering canonicalised via sorted keys".
const a = encodeBinary({ a: 1, b: 2, c: 3 });
const b = encodeBinary({ c: 3, a: 1, b: 2 });
expect(toHex(a)).toBe(toHex(b));
});
it("nested objects with reordered keys produce identical bytes", {
timeout: 5000,
}, () => {
const a = encodeBinary({
outer: { x: 1, y: 2 },
list: [{ k: "v" }],
});
const b = encodeBinary({
list: [{ k: "v" }],
outer: { y: 2, x: 1 },
});
expect(toHex(a)).toBe(toHex(b));
});
});

View File

@@ -0,0 +1,182 @@
import {
Encoder,
ExtensionCodec,
type ExtensionCodecType,
} from "@msgpack/msgpack";
import {
CircularReferenceError,
InvalidOptionsError,
MaxDepthExceededError,
MaxSizeExceededError,
SerializationError,
} from "../../errors/serialize-error.js";
import { getSerializeMetrics } from "../../observability/metrics.js";
import { getTypeByCtor } from "../../registry/registry.js";
import type { SerializeOptions } from "../json/json-encode.js";
const ALLOWED_ENCODE_BINARY_KEYS = new Set(["maxDepth", "maxSize", "signal"]);
function validateEncodeBinaryOptions(opts?: SerializeOptions): void {
if (opts === undefined) return;
for (const key of Object.keys(opts as object)) {
if (!ALLOWED_ENCODE_BINARY_KEYS.has(key)) {
throw new InvalidOptionsError(
`unknown encodeBinary option: ${key}`
);
}
}
if (opts.maxSize !== undefined && opts.maxSize <= 0) {
throw new InvalidOptionsError(
`maxSize must be > 0, got ${opts.maxSize}`
);
}
}
/** Extension type ID reserved for tagged registry objects. */
const _TAG_EXT_TYPE = 1;
function buildEncodeCodec(): ExtensionCodecType<undefined> {
const codec = new ExtensionCodec<undefined>();
// No custom ext registered here — we pre-process values before handing to
// msgpack so plain msgpack-compatible intermediates are produced.
return codec;
}
/**
* Recursively pre-encode a value into a plain msgpack-compatible structure,
* wrapping tagged types as `{ __t, v }` maps.
*/
function preEncode(
val: unknown,
depth: number,
maxDepth: number,
seen: WeakSet<object>,
path: string
): unknown {
if (depth > maxDepth) {
throw new MaxDepthExceededError(depth);
}
if (val === null) return null;
if (val === undefined) return { __t: "__undefined", v: null };
if (typeof val === "boolean" || typeof val === "string") return val;
if (typeof val === "number") {
if (Number.isNaN(val)) return { __t: "__NaN", v: null };
if (val === Number.POSITIVE_INFINITY)
return { __t: "__Infinity", v: "+" };
if (val === Number.NEGATIVE_INFINITY)
return { __t: "__Infinity", v: "-" };
return val;
}
if (typeof val === "bigint") {
return { __t: "__BigInt", v: val.toString() };
}
if (typeof val === "object") {
if (seen.has(val)) {
throw new CircularReferenceError(path);
}
seen.add(val);
try {
const def = getTypeByCtor(
(val as object).constructor as CallableFunction
);
if (def !== undefined) {
const encoded = def.encode(val);
return {
__t: def.tag,
v: preEncode(
encoded,
depth + 1,
maxDepth,
seen,
`${path}.__encoded`
),
};
}
if (Array.isArray(val)) {
return val.map((item, i) =>
preEncode(item, depth + 1, maxDepth, seen, `${path}[${i}]`)
);
}
// Sort keys canonically for bit-identical output
const result: Record<string, unknown> = {};
const keys = Object.keys(val as Record<string, unknown>).sort();
for (const key of keys) {
const encoded = preEncode(
(val as Record<string, unknown>)[key],
depth + 1,
maxDepth,
seen,
`${path}.${key}`
);
// Skip non-serializable values (functions, symbols) — mirrors JSON.stringify behavior
if (encoded !== undefined) {
result[key] = encoded;
}
}
return result;
} finally {
seen.delete(val);
}
}
return undefined;
}
const _encoderCodec = buildEncodeCodec();
const _encoder = new Encoder({
extensionCodec: _encoderCodec,
forceIntegerToFloat: false,
});
/**
* Encode any JavaScript value to a MessagePack binary buffer.
*
* Uses the same type registry as `serialize` so any value that survives a
* JSON round-trip will also survive a binary round-trip. Object keys are
* sorted canonically before encoding to guarantee bit-identical output for
* structurally equivalent values.
*/
export function encodeBinary(
value: unknown,
opts?: SerializeOptions
): Uint8Array {
validateEncodeBinaryOptions(opts);
const m = getSerializeMetrics();
const start = Date.now();
const maxDepth = opts?.maxDepth ?? 100;
const maxSize = opts?.maxSize;
const signal = opts?.signal;
signal?.throwIfAborted();
try {
const seen = new WeakSet<object>();
const intermediate = preEncode(value, 0, maxDepth, seen, "root");
const bytes = _encoder.encode(intermediate);
if (maxSize !== undefined && bytes.byteLength > maxSize) {
throw new MaxSizeExceededError(bytes.byteLength);
}
m.encodeBytes.record(bytes.byteLength, { format: "msgpack" });
m.payloadBytes.record(bytes.byteLength, { format: "msgpack" });
m.encodeDuration.record(Date.now() - start, { format: "msgpack" });
return bytes;
} catch (err) {
const code =
err instanceof SerializationError
? err.code
: "SERIALIZE.CODEC.UNKNOWN";
m.errorTotal.add(1, { code, format: "msgpack" });
throw err;
}
}

View File

@@ -0,0 +1,233 @@
import { Encoder } from "@msgpack/msgpack";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import {
CircularReferenceError,
MaxDepthExceededError,
MaxSizeExceededError,
UnsupportedTypeError,
} from "../../errors/serialize-error.js";
import { registerType, unregisterType } from "../../registry/registry.js";
import { decodeBinary } from "./msgpack-decode.js";
import { encodeBinary } from "./msgpack-encode.js";
const CUSTOM_TAG = "__TestVec";
class Vec2 {
public constructor(
public x: number,
public y: number
) {}
}
afterEach(() => {
unregisterType(CUSTOM_TAG);
});
describe("encodeBinary / decodeBinary: primitives", () => {
it("string round-trips", { timeout: 5000 }, () => {
expect(decodeBinary(encodeBinary("hello"))).toBe("hello");
});
it("integer round-trips", { timeout: 5000 }, () => {
expect(decodeBinary(encodeBinary(42))).toBe(42);
});
it("boolean round-trips", { timeout: 5000 }, () => {
expect(decodeBinary(encodeBinary(false))).toBe(false);
});
it("null round-trips", { timeout: 5000 }, () => {
expect(decodeBinary(encodeBinary(null))).toBe(null);
});
it("undefined round-trips", { timeout: 5000 }, () => {
expect(decodeBinary(encodeBinary(undefined))).toBeUndefined();
});
it("NaN round-trips", { timeout: 5000 }, () => {
expect(
Number.isNaN(decodeBinary(encodeBinary(Number.NaN)) as number)
).toBe(true);
});
it("+Infinity round-trips", { timeout: 5000 }, () => {
expect(decodeBinary(encodeBinary(Number.POSITIVE_INFINITY))).toBe(
Number.POSITIVE_INFINITY
);
});
it("-Infinity round-trips", { timeout: 5000 }, () => {
expect(decodeBinary(encodeBinary(Number.NEGATIVE_INFINITY))).toBe(
Number.NEGATIVE_INFINITY
);
});
});
describe("encodeBinary / decodeBinary: built-in types", () => {
it("Date round-trips", { timeout: 5000 }, () => {
const d = new Date("2024-03-15T10:00:00.000Z");
const result = decodeBinary(encodeBinary(d)) as Date;
expect(result).toBeInstanceOf(Date);
expect(result.toISOString()).toBe(d.toISOString());
});
it("Map round-trips", { timeout: 5000 }, () => {
const m = new Map([
["a", 1],
["b", 2],
]);
const result = decodeBinary(encodeBinary(m)) as Map<string, number>;
expect(result).toBeInstanceOf(Map);
expect(result.get("b")).toBe(2);
});
it("Set round-trips", { timeout: 5000 }, () => {
const s = new Set(["x", "y", "z"]);
const result = decodeBinary(encodeBinary(s)) as Set<string>;
expect(result).toBeInstanceOf(Set);
expect(result.has("y")).toBe(true);
});
it("BigInt round-trips", { timeout: 5000 }, () => {
const n = 9007199254740993n;
expect(decodeBinary(encodeBinary(n))).toBe(n);
});
it("Uint8Array round-trips", { timeout: 5000 }, () => {
const arr = new Uint8Array([10, 20, 30]);
const result = decodeBinary(encodeBinary(arr)) as Uint8Array;
expect(result).toBeInstanceOf(Uint8Array);
expect(Array.from(result)).toEqual([10, 20, 30]);
});
it("URL round-trips", { timeout: 5000 }, () => {
const url = new URL("https://suaveplan.io/api?v=1");
const result = decodeBinary(encodeBinary(url)) as URL;
expect(result).toBeInstanceOf(URL);
expect(result.href).toBe(url.href);
});
it("RegExp round-trips", { timeout: 5000 }, () => {
const re = /test/im;
const result = decodeBinary(encodeBinary(re)) as RegExp;
expect(result).toBeInstanceOf(RegExp);
expect(result.source).toBe("test");
expect(result.flags).toBe("im");
});
});
describe("encodeBinary / decodeBinary: nested structures", () => {
it("plain object round-trips", { timeout: 5000 }, () => {
const obj = { x: 1, y: "two", z: true };
expect(decodeBinary(encodeBinary(obj))).toEqual(obj);
});
it("array round-trips", { timeout: 5000 }, () => {
const arr = [1, "two", null, false];
expect(decodeBinary(encodeBinary(arr))).toEqual(arr);
});
it("nested map of dates", { timeout: 5000 }, () => {
const m = new Map([["ts", new Date("2024-01-01")]]);
const result = decodeBinary(encodeBinary(m)) as Map<string, Date>;
expect(result.get("ts")).toBeInstanceOf(Date);
});
});
describe("encodeBinary: structural equivalence with JSON codec", () => {
it("produces structurally equivalent output", { timeout: 5000 }, () => {
const obj = {
name: "test",
date: new Date("2024-01-01T00:00:00.000Z"),
count: 42,
};
const fromBinary = decodeBinary(encodeBinary(obj)) as typeof obj;
expect(fromBinary.name).toBe("test");
expect(fromBinary.date).toBeInstanceOf(Date);
expect(fromBinary.count).toBe(42);
});
});
describe("encodeBinary: error conditions", () => {
it("circular reference throws CircularReferenceError", {
timeout: 5000,
}, () => {
const obj: Record<string, unknown> = {};
obj["self"] = obj;
expect(() => encodeBinary(obj)).toThrowError(CircularReferenceError);
});
it("max depth exceeded throws MaxDepthExceededError", {
timeout: 5000,
}, () => {
const deep: Record<string, unknown> = {};
let cur = deep;
for (let i = 0; i < 10; i++) {
cur["child"] = {};
cur = cur["child"] as Record<string, unknown>;
}
expect(() => encodeBinary(deep, { maxDepth: 3 })).toThrowError(
MaxDepthExceededError
);
});
it("max size exceeded throws MaxSizeExceededError", {
timeout: 5000,
}, () => {
const obj = { data: "x".repeat(1000) };
expect(() => encodeBinary(obj, { maxSize: 10 })).toThrowError(
MaxSizeExceededError
);
});
it("aborted signal throws immediately", { timeout: 5000 }, () => {
const controller = new AbortController();
controller.abort(new Error("cancelled"));
expect(() =>
encodeBinary({ a: 1 }, { signal: controller.signal })
).toThrow();
});
});
describe("decodeBinary: error conditions", () => {
it("unknown tag throws UnsupportedTypeError", { timeout: 5000 }, () => {
// Manually craft msgpack with unknown tag
const enc = new Encoder();
const bytes = enc.encode({ __t: "__NonExistentTag999", v: null });
expect(() => decodeBinary(bytes)).toThrowError(UnsupportedTypeError);
});
});
describe("encodeBinary: function and symbol values are skipped", () => {
it("function value in object is omitted", { timeout: 5000 }, () => {
const obj: Record<string, unknown> = { a: 1, fn: () => 42 };
const result = decodeBinary(encodeBinary(obj)) as Record<
string,
unknown
>;
expect(result["a"]).toBe(1);
expect("fn" in result).toBe(false);
});
});
describe("encodeBinary: custom registered type", () => {
it("custom type round-trips via binary", { timeout: 5000 }, () => {
registerType({
tag: CUSTOM_TAG,
ctor: Vec2,
encode: (v) => {
const vec = v as Vec2;
return [vec.x, vec.y];
},
decode: (enc) => {
const [x, y] = enc as [number, number];
return new Vec2(x, y);
},
});
const v = new Vec2(5, 10);
const result = decodeBinary(encodeBinary(v)) as Vec2;
expect(result).toBeInstanceOf(Vec2);
expect(result.x).toBe(5);
expect(result.y).toBe(10);
});
});