Initial Commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export { decodeBinary } from "./msgpack-decode";
|
||||
export { encodeBinary } from "./msgpack-encode";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user