Initial Commit
This commit is contained in:
20
packages/foundation/data/serialize/src/codec/index.ts
Normal file
20
packages/foundation/data/serialize/src/codec/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type { DeserializeOptions } from "./json/json-decode.js";
|
||||
export { deserialize } from "./json/json-decode.js";
|
||||
export type { SerializeOptions } from "./json/json-encode.js";
|
||||
export { serialize } from "./json/json-encode.js";
|
||||
export { decodeBinary } from "./msgpack/msgpack-decode.js";
|
||||
export { encodeBinary } from "./msgpack/msgpack-encode.js";
|
||||
export { decodeStream, encodeStream } from "./stream/stream.js";
|
||||
|
||||
import type { deserialize } from "./json/json-decode.js";
|
||||
import type { serialize } from "./json/json-encode.js";
|
||||
import type { decodeBinary } from "./msgpack/msgpack-decode.js";
|
||||
import type { encodeBinary } from "./msgpack/msgpack-encode.js";
|
||||
|
||||
/** Minimal codec interface for injection/testing scenarios. */
|
||||
export interface Codec {
|
||||
serialize: typeof serialize;
|
||||
deserialize: typeof deserialize;
|
||||
encodeBinary: typeof encodeBinary;
|
||||
decodeBinary: typeof decodeBinary;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
DeserializeOptionsSchema,
|
||||
SerializeOptionsSchema,
|
||||
} from "./json.schemas";
|
||||
export { type DeserializeOptions, deserialize } from "./json-decode";
|
||||
export { type SerializeOptions, serialize } from "./json-encode";
|
||||
@@ -0,0 +1,85 @@
|
||||
# json-decode
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `deserialize`, the inverse of `serialize`. Given a tagged JSON string produced by the encoder, it reconstructs the original JavaScript value graph including all registered types (built-ins and custom), `undefined`, `NaN`, and `±Infinity`. Any `__t` tag that has no registered handler causes an `UnsupportedTypeError` to be thrown immediately rather than silently discarding data.
|
||||
|
||||
The decoder is intentionally strict: it is safer to fail loudly on an unrecognised tag than to silently drop values, which could cause subtle data corruption downstream.
|
||||
|
||||
## Features
|
||||
|
||||
- Full inverse of `serialize`: all built-in type tags are decoded back to their original JavaScript types
|
||||
- `AbortSignal` support for cancelling mid-traversal on large payloads, checked every 256 nodes
|
||||
- `UnsupportedTypeError` thrown (with the tag name in `context.tag`) for unrecognised `__t` values
|
||||
- Recursive array and plain-object decoding
|
||||
- Zero allocations for pass-through primitives (string, number, boolean, null)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { deserialize, serialize } from "@suaveplan/serialize";
|
||||
|
||||
const original = { created: new Date(), tags: new Set(["x", "y"]) };
|
||||
const json = serialize(original);
|
||||
const restored = deserialize(json) as typeof original;
|
||||
|
||||
restored.created instanceof Date; // true
|
||||
restored.tags instanceof Set; // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { deserialize, UnsupportedTypeError } from "@suaveplan/serialize";
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
// Cancel deserialization partway through:
|
||||
setTimeout(() => controller.abort(new Error("timeout")), 10);
|
||||
|
||||
try {
|
||||
const value = deserialize(largeJson, { signal: controller.signal });
|
||||
processValue(value);
|
||||
} catch (err) {
|
||||
if (err instanceof UnsupportedTypeError) {
|
||||
console.error("Unknown type tag:", err.context?.tag);
|
||||
} else {
|
||||
// likely the abort reason
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `deserialize(json: string, opts?: DeserializeOptions): unknown`
|
||||
|
||||
Parse `json` and recursively reconstruct the JavaScript value it encodes.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `json` — a tagged JSON string produced by `serialize`
|
||||
- `opts.signal` — an `AbortSignal`; checked every 256 nodes
|
||||
|
||||
**Returns:** the reconstructed value.
|
||||
|
||||
**Throws:**
|
||||
|
||||
- `UnsupportedTypeError` when a `__t` tag has no registered decoder
|
||||
- `SyntaxError` from `JSON.parse` if the input is not valid JSON
|
||||
- The `AbortSignal`'s reason when `signal` is aborted
|
||||
|
||||
### `DeserializeOptions`
|
||||
|
||||
```typescript
|
||||
interface DeserializeOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The decoder calls `JSON.parse` once at the top level and then recursively traverses the parsed intermediate tree. This avoids parsing partial JSON strings and keeps the implementation simple.
|
||||
- For tagged objects, the inner `v` payload is decoded recursively *before* being handed to `def.decode`. This means registered type decoders always receive already-decoded JavaScript values, not raw JSON intermediates.
|
||||
- Signal checking is shared with a module-level counter that resets per `deserialize` call; it fires every 256 traversed nodes.
|
||||
- The decoder does not validate the structure of `v` beyond calling `def.decode`; responsibility for validating the payload shape belongs to each registered type definition.
|
||||
113
packages/foundation/data/serialize/src/codec/json/json-decode.ts
Normal file
113
packages/foundation/data/serialize/src/codec/json/json-decode.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
InvalidOptionsError,
|
||||
MalformedWireError,
|
||||
SerializationError,
|
||||
UnsupportedTypeError,
|
||||
} from "../../errors/serialize-error.js";
|
||||
import { getSerializeMetrics } from "../../observability/metrics.js";
|
||||
import { getType } from "../../registry/registry.js";
|
||||
|
||||
/** Options controlling the behavior of `deserialize`. */
|
||||
export interface DeserializeOptions {
|
||||
/** An `AbortSignal` that cancels decoding mid-traversal. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const SIGNAL_CHECK_INTERVAL = 256;
|
||||
const ALLOWED_DESERIALIZE_KEYS = new Set(["signal"]);
|
||||
|
||||
function validateDeserializeOptions(opts?: DeserializeOptions): void {
|
||||
if (opts === undefined) return;
|
||||
for (const key of Object.keys(opts as object)) {
|
||||
if (!ALLOWED_DESERIALIZE_KEYS.has(key)) {
|
||||
throw new InvalidOptionsError(`unknown deserialize option: ${key}`);
|
||||
}
|
||||
}
|
||||
if (opts.signal !== undefined && !(opts.signal instanceof AbortSignal)) {
|
||||
throw new InvalidOptionsError("signal must be an AbortSignal");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a JavaScript value from a tagged JSON string produced by
|
||||
* `serialize`. Invalid JSON or malformed envelopes throw `MalformedWireError`;
|
||||
* unknown `__t` tags throw `UnsupportedTypeError`.
|
||||
*/
|
||||
export function deserialize<T = unknown>(
|
||||
json: string,
|
||||
opts?: DeserializeOptions
|
||||
): T {
|
||||
validateDeserializeOptions(opts);
|
||||
|
||||
const m = getSerializeMetrics();
|
||||
const start = Date.now();
|
||||
const signal = opts?.signal;
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch (cause) {
|
||||
const err = new MalformedWireError("JSON.parse", undefined, cause);
|
||||
m.errorTotal.add(1, { code: err.code, format: "json" });
|
||||
throw err;
|
||||
}
|
||||
|
||||
let nodeCount = 0;
|
||||
|
||||
function decodeValue(val: unknown): unknown {
|
||||
nodeCount++;
|
||||
|
||||
if (nodeCount % SIGNAL_CHECK_INTERVAL === 0) {
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
|
||||
if (val === null || val === undefined) return val;
|
||||
if (typeof val !== "object") return val;
|
||||
|
||||
if (Array.isArray(val)) {
|
||||
return val.map((item) => decodeValue(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 = decodeValue(obj["v"]);
|
||||
return def.decode(decodedPayload);
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = Object.create(null) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
for (const key of Object.keys(obj)) {
|
||||
Object.defineProperty(result, key, {
|
||||
value: decodeValue(obj[key]),
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = decodeValue(parsed);
|
||||
m.decodeBytes.record(json.length, { format: "json" });
|
||||
m.decodeDuration.record(Date.now() - start, { format: "json" });
|
||||
return result as T;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err instanceof SerializationError
|
||||
? err.code
|
||||
: "SERIALIZE.CODEC.UNKNOWN";
|
||||
m.errorTotal.add(1, { code, format: "json" });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# json-encode
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the `serialize` function that converts any JavaScript value to a tagged JSON string preserving type information beyond what `JSON.stringify` supports. The encoder traverses the value graph recursively, wraps non-primitive types in `{ "__t": tag, "v": ... }` envelopes handled by the type registry, and detects both cycles and excessive nesting depth before they cause runtime errors.
|
||||
|
||||
The tagged JSON format is self-describing: a receiver that imports `deserialize` from the same package can reconstruct the original value graph without out-of-band schema information.
|
||||
|
||||
## Features
|
||||
|
||||
- Handles all types registered in the global registry (Date, Map, Set, BigInt, TypedArrays, URL, RegExp, custom classes)
|
||||
- Preserves `undefined` as `{"__t":"__undefined","v":null}` (normally silently dropped by `JSON.stringify`)
|
||||
- Preserves `NaN` as `{"__t":"__NaN","v":null}` and `±Infinity` as `{"__t":"__Infinity","v":"+"/"−"}`
|
||||
- Detects circular references via `WeakSet` and throws `CircularReferenceError` with the structural path
|
||||
- Configurable maximum depth (default 100) via `maxDepth` option; throws `MaxDepthExceededError`
|
||||
- Configurable maximum byte length via `maxSize` option; throws `MaxSizeExceededError`
|
||||
- Honour `AbortSignal` cancellation; checked every 256 nodes for minimal overhead
|
||||
- `seen` set cleared per-branch (re-entrant DAG nodes are allowed, only true cycles throw)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { serialize } from "@suaveplan/serialize";
|
||||
|
||||
const obj = {
|
||||
created: new Date("2024-01-01"),
|
||||
tags: new Set(["a", "b"]),
|
||||
count: BigInt(9007199254740993),
|
||||
};
|
||||
|
||||
const json = serialize(obj);
|
||||
// '{"created":{"__t":"__Date","v":"2024-01-01T00:00:00.000Z"},...}'
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { serialize } from "@suaveplan/serialize";
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
// Enforce max 50KB output and max 20 levels of nesting
|
||||
const json = serialize(hugeObject, {
|
||||
maxDepth: 20,
|
||||
maxSize: 50_000,
|
||||
signal: controller.signal,
|
||||
});
|
||||
sendToNetwork(json);
|
||||
} catch (err) {
|
||||
if (err instanceof MaxSizeExceededError) {
|
||||
console.error("Payload too large:", err.context?.size, "bytes");
|
||||
} else if (err instanceof MaxDepthExceededError) {
|
||||
console.error("Too deeply nested at depth:", err.context?.depth);
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel mid-way through a large serialization:
|
||||
controller.abort();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `serialize(value: unknown, opts?: SerializeOptions): string`
|
||||
|
||||
Converts `value` to a tagged JSON string.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `value` — any JavaScript value; functions and symbols are silently dropped (same as `JSON.stringify`)
|
||||
- `opts.maxDepth` — maximum recursion depth before `MaxDepthExceededError` is thrown (default: `100`)
|
||||
- `opts.maxSize` — maximum byte length of the resulting JSON string; checked after encoding (default: unlimited)
|
||||
- `opts.signal` — an `AbortSignal`; checked every 256 nodes; throws the signal's reason if aborted
|
||||
|
||||
**Returns:** a JSON string in the tagged wire format.
|
||||
|
||||
**Throws:**
|
||||
|
||||
- `CircularReferenceError` when a cycle is detected
|
||||
- `MaxDepthExceededError` when nesting exceeds `maxDepth`
|
||||
- `MaxSizeExceededError` when output exceeds `maxSize` bytes
|
||||
- The `AbortSignal`'s reason when `signal` is aborted
|
||||
|
||||
### `SerializeOptions`
|
||||
|
||||
```typescript
|
||||
interface SerializeOptions {
|
||||
maxDepth?: number; // default: 100
|
||||
maxSize?: number; // default: unlimited
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The cycle-detection `WeakSet` (`seen`) is managed with add-before/delete-after so that DAG structures (same object reachable via multiple paths) do not falsely trigger the cycle check. Only true back-edges to an ancestor in the current call stack throw.
|
||||
- Signal checking happens every 256 nodes rather than every node to keep the hot path free of extra branch overhead.
|
||||
- The `maxSize` check occurs **after** `JSON.stringify` on the fully encoded intermediate object, not during encoding. This trades one additional pass for implementation simplicity and avoids double-counting tag overhead.
|
||||
- `undefined` at object property positions is omitted by `JSON.stringify` but handled explicitly here by converting it to a tagged sentinel before the `JSON.stringify` call.
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { CircularReferenceError } from "../../errors/serialize-error.js";
|
||||
import { deserialize } from "./json-decode.js";
|
||||
import { serialize } from "./json-encode.js";
|
||||
|
||||
/**
|
||||
* Property-based tests: for any plain object or array that contains no
|
||||
* circular references, `deserialize(serialize(x))` deep-equals `x`.
|
||||
*/
|
||||
describe("property: serialize/deserialize round-trip", () => {
|
||||
it("arbitrary plain objects (no circular, no special types)", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.object({
|
||||
// Restrict to JSON-safe primitives so round-trip is exact
|
||||
values: [
|
||||
fc.string(),
|
||||
fc.integer(),
|
||||
fc.boolean(),
|
||||
fc.constant(null),
|
||||
],
|
||||
maxDepth: 4,
|
||||
maxKeys: 8,
|
||||
}),
|
||||
(obj) => {
|
||||
const json = serialize(obj);
|
||||
const restored = deserialize(json);
|
||||
expect(restored).toEqual(obj);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it("arbitrary arrays", { timeout: 30000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.oneof(
|
||||
fc.string(),
|
||||
fc.integer(),
|
||||
fc.boolean(),
|
||||
fc.constant(null)
|
||||
),
|
||||
{ maxLength: 20 }
|
||||
),
|
||||
(arr) => {
|
||||
const json = serialize(arr);
|
||||
const restored = deserialize(json);
|
||||
expect(restored).toEqual(arr);
|
||||
}
|
||||
),
|
||||
{ numRuns: 200 }
|
||||
);
|
||||
});
|
||||
|
||||
it("nested objects", { timeout: 30000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.object({
|
||||
values: [fc.string(), fc.integer()],
|
||||
maxDepth: 3,
|
||||
maxKeys: 5,
|
||||
}),
|
||||
(obj) => {
|
||||
expect(deserialize(serialize(obj))).toEqual(obj);
|
||||
}
|
||||
),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("property: circular reference always throws", () => {
|
||||
it("object with self-reference always throws CircularReferenceError", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string(), (key) => {
|
||||
// Use a null-prototype object so generated keys like
|
||||
// "__proto__" become real own properties instead of invoking
|
||||
// the inherited Object.prototype.__proto__ setter (which
|
||||
// throws TypeError "cyclic __proto__ value" before serialize
|
||||
// is ever called).
|
||||
const obj: Record<string, unknown> = Object.create(
|
||||
null
|
||||
) as Record<string, unknown>;
|
||||
obj[key] = obj;
|
||||
expect(() => serialize(obj)).toThrowError(
|
||||
CircularReferenceError
|
||||
);
|
||||
}),
|
||||
{ numRuns: 50 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Spec-conformance tests for the JSON codec. Covers the two scenarios in
|
||||
* `openspec/specs/serialization/spec.md` that the original test suite did
|
||||
* not pin explicitly:
|
||||
*
|
||||
* - SZ-02 — every typed array (`Int8Array`, `Int16Array`, `Int32Array`,
|
||||
* `Uint16Array`, `Uint32Array`, `Float32Array`, `Float64Array`) MUST
|
||||
* round-trip through `serialize → deserialize`. The original suite only
|
||||
* exercised the registry's encode/decode functions directly for these
|
||||
* types and `Uint8Array` end-to-end.
|
||||
* - SZ-03 — the tagged-JSON wire format MUST use a `{ "__t": "<tag>",
|
||||
* "v": <encoded> }` envelope. The original suite checked the envelope
|
||||
* for sentinels (`__undefined`, `__NaN`, `__Infinity`) but not for
|
||||
* registered built-in types like `Date`.
|
||||
*/
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { InvalidOptionsError } from "../../errors/serialize-error.js";
|
||||
import { deserialize } from "./json-decode.js";
|
||||
import { serialize } from "./json-encode.js";
|
||||
|
||||
describe("SZ-02 — typed-array end-to-end round-trip", () => {
|
||||
it("Int8Array round-trips through serialize/deserialize", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = new Int8Array([-128, 0, 127]);
|
||||
const result = deserialize(serialize(original)) as Int8Array;
|
||||
expect(result).toBeInstanceOf(Int8Array);
|
||||
expect(Array.from(result)).toEqual([-128, 0, 127]);
|
||||
});
|
||||
|
||||
it("Int16Array round-trips through serialize/deserialize", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = new Int16Array([-32_768, 0, 32_767]);
|
||||
const result = deserialize(serialize(original)) as Int16Array;
|
||||
expect(result).toBeInstanceOf(Int16Array);
|
||||
expect(Array.from(result)).toEqual([-32_768, 0, 32_767]);
|
||||
});
|
||||
|
||||
it("Int32Array round-trips through serialize/deserialize", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = new Int32Array([-2_147_483_648, 0, 2_147_483_647]);
|
||||
const result = deserialize(serialize(original)) as Int32Array;
|
||||
expect(result).toBeInstanceOf(Int32Array);
|
||||
expect(Array.from(result)).toEqual([-2_147_483_648, 0, 2_147_483_647]);
|
||||
});
|
||||
|
||||
it("Uint16Array round-trips through serialize/deserialize", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = new Uint16Array([0, 1, 65_535]);
|
||||
const result = deserialize(serialize(original)) as Uint16Array;
|
||||
expect(result).toBeInstanceOf(Uint16Array);
|
||||
expect(Array.from(result)).toEqual([0, 1, 65_535]);
|
||||
});
|
||||
|
||||
it("Uint32Array round-trips through serialize/deserialize", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = new Uint32Array([0, 1, 4_294_967_295]);
|
||||
const result = deserialize(serialize(original)) as Uint32Array;
|
||||
expect(result).toBeInstanceOf(Uint32Array);
|
||||
expect(Array.from(result)).toEqual([0, 1, 4_294_967_295]);
|
||||
});
|
||||
|
||||
it("Float32Array round-trips through serialize/deserialize", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = new Float32Array([1.5, -2.25, 0]);
|
||||
const result = deserialize(serialize(original)) as Float32Array;
|
||||
expect(result).toBeInstanceOf(Float32Array);
|
||||
expect(Array.from(result)).toEqual([1.5, -2.25, 0]);
|
||||
});
|
||||
|
||||
it("Float64Array round-trips through serialize/deserialize", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const original = new Float64Array([Math.PI, -Math.E, 0]);
|
||||
const result = deserialize(serialize(original)) as Float64Array;
|
||||
expect(result).toBeInstanceOf(Float64Array);
|
||||
expect(result[0]).toBeCloseTo(Math.PI, 12);
|
||||
expect(result[1]).toBeCloseTo(-Math.E, 12);
|
||||
expect(result[2]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deserialize options validation", () => {
|
||||
it("throws InvalidOptionsError for unknown option key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => deserialize('"hello"', { badKey: true } as never)).toThrow(
|
||||
InvalidOptionsError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SZ-03 — tagged JSON envelope shape", () => {
|
||||
it("Date encodes to { __t: '__Date', v: <iso> } envelope", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const d = new Date("2024-01-15T12:00:00.000Z");
|
||||
const json = serialize(d);
|
||||
const parsed = JSON.parse(json) as Record<string, unknown>;
|
||||
expect(parsed).toEqual({
|
||||
__t: "__Date",
|
||||
v: "2024-01-15T12:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("Map encodes to envelope with __Map tag", { timeout: 5000 }, () => {
|
||||
const m = new Map([["k", 1]]);
|
||||
const json = serialize(m);
|
||||
const parsed = JSON.parse(json) as Record<string, unknown>;
|
||||
expect(parsed["__t"]).toBe("__Map");
|
||||
expect(parsed["v"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
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 { deserialize } from "./json-decode.js";
|
||||
import { serialize } from "./json-encode.js";
|
||||
|
||||
const CUSTOM_TAG = "__TestPoint";
|
||||
class Point {
|
||||
public constructor(
|
||||
public x: number,
|
||||
public y: number
|
||||
) {}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
unregisterType(CUSTOM_TAG);
|
||||
});
|
||||
|
||||
describe("serialize: primitives", () => {
|
||||
it("string passthrough", { timeout: 5000 }, () => {
|
||||
expect(JSON.parse(serialize("hello"))).toBe("hello");
|
||||
});
|
||||
|
||||
it("number passthrough", { timeout: 5000 }, () => {
|
||||
expect(JSON.parse(serialize(42))).toBe(42);
|
||||
});
|
||||
|
||||
it("boolean passthrough", { timeout: 5000 }, () => {
|
||||
expect(JSON.parse(serialize(true))).toBe(true);
|
||||
});
|
||||
|
||||
it("null passthrough", { timeout: 5000 }, () => {
|
||||
expect(JSON.parse(serialize(null))).toBe(null);
|
||||
});
|
||||
|
||||
it("undefined encodes to __undefined sentinel", { timeout: 5000 }, () => {
|
||||
const result = JSON.parse(serialize(undefined));
|
||||
expect(result).toEqual({ __t: "__undefined", v: null });
|
||||
});
|
||||
|
||||
it("NaN encodes to __NaN sentinel", { timeout: 5000 }, () => {
|
||||
const result = JSON.parse(serialize(Number.NaN));
|
||||
expect(result).toEqual({ __t: "__NaN", v: null });
|
||||
});
|
||||
|
||||
it("+Infinity encodes to __Infinity sentinel", { timeout: 5000 }, () => {
|
||||
const result = JSON.parse(serialize(Number.POSITIVE_INFINITY));
|
||||
expect(result).toEqual({ __t: "__Infinity", v: "+" });
|
||||
});
|
||||
|
||||
it("-Infinity encodes to __Infinity sentinel", { timeout: 5000 }, () => {
|
||||
const result = JSON.parse(serialize(Number.NEGATIVE_INFINITY));
|
||||
expect(result).toEqual({ __t: "__Infinity", v: "-" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("serialize: built-in types", () => {
|
||||
it("Date round-trips", { timeout: 5000 }, () => {
|
||||
const d = new Date("2024-01-15T12:00:00.000Z");
|
||||
expect(deserialize(serialize(d))).toEqual(d);
|
||||
});
|
||||
|
||||
it("Map round-trips", { timeout: 5000 }, () => {
|
||||
const m = new Map([
|
||||
["a", 1],
|
||||
["b", 2],
|
||||
]);
|
||||
const result = deserialize(serialize(m)) as Map<string, number>;
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.get("a")).toBe(1);
|
||||
});
|
||||
|
||||
it("Set round-trips", { timeout: 5000 }, () => {
|
||||
const s = new Set([10, 20, 30]);
|
||||
const result = deserialize(serialize(s)) as Set<number>;
|
||||
expect(result).toBeInstanceOf(Set);
|
||||
expect(result.has(20)).toBe(true);
|
||||
});
|
||||
|
||||
it("BigInt round-trips", { timeout: 5000 }, () => {
|
||||
const n = BigInt("9007199254740993");
|
||||
expect(deserialize(serialize(n))).toBe(n);
|
||||
});
|
||||
|
||||
it("Uint8Array round-trips", { timeout: 5000 }, () => {
|
||||
const arr = new Uint8Array([0, 128, 255]);
|
||||
const result = deserialize(serialize(arr)) as Uint8Array;
|
||||
expect(result).toBeInstanceOf(Uint8Array);
|
||||
expect(Array.from(result)).toEqual([0, 128, 255]);
|
||||
});
|
||||
|
||||
it("URL round-trips", { timeout: 5000 }, () => {
|
||||
const url = new URL("https://example.com/path?q=1#hash");
|
||||
const result = deserialize(serialize(url)) as URL;
|
||||
expect(result).toBeInstanceOf(URL);
|
||||
expect(result.href).toBe(url.href);
|
||||
});
|
||||
|
||||
it("RegExp round-trips", { timeout: 5000 }, () => {
|
||||
const re = /hello/gi;
|
||||
const result = deserialize(serialize(re)) as RegExp;
|
||||
expect(result).toBeInstanceOf(RegExp);
|
||||
expect(result.source).toBe("hello");
|
||||
expect(result.flags).toBe("gi");
|
||||
});
|
||||
});
|
||||
|
||||
describe("serialize: special values round-trip", () => {
|
||||
it("undefined round-trips", { timeout: 5000 }, () => {
|
||||
expect(deserialize(serialize(undefined))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("NaN round-trips", { timeout: 5000 }, () => {
|
||||
expect(Number.isNaN(deserialize(serialize(Number.NaN)) as number)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("+Infinity round-trips", { timeout: 5000 }, () => {
|
||||
expect(deserialize(serialize(Number.POSITIVE_INFINITY))).toBe(
|
||||
Number.POSITIVE_INFINITY
|
||||
);
|
||||
});
|
||||
|
||||
it("-Infinity round-trips", { timeout: 5000 }, () => {
|
||||
expect(deserialize(serialize(Number.NEGATIVE_INFINITY))).toBe(
|
||||
Number.NEGATIVE_INFINITY
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serialize: nested structures", () => {
|
||||
it("plain object round-trips", { timeout: 5000 }, () => {
|
||||
const obj = { a: 1, b: "hello", c: true };
|
||||
expect(deserialize(serialize(obj))).toEqual(obj);
|
||||
});
|
||||
|
||||
it("array round-trips", { timeout: 5000 }, () => {
|
||||
const arr = [1, "two", true, null];
|
||||
expect(deserialize(serialize(arr))).toEqual(arr);
|
||||
});
|
||||
|
||||
it("nested objects", { timeout: 5000 }, () => {
|
||||
const obj = { outer: { inner: { value: 42 } } };
|
||||
expect(deserialize(serialize(obj))).toEqual(obj);
|
||||
});
|
||||
|
||||
it("array of dates", { timeout: 5000 }, () => {
|
||||
const dates = [new Date("2024-01-01"), new Date("2024-06-15")];
|
||||
const result = deserialize(serialize(dates)) as Date[];
|
||||
expect(result[0]).toBeInstanceOf(Date);
|
||||
expect(result[1]?.toISOString()).toBe("2024-06-15T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("object with undefined property", { timeout: 5000 }, () => {
|
||||
const obj = { a: 1, b: undefined, c: 3 };
|
||||
const result = deserialize(serialize(obj)) as Record<string, unknown>;
|
||||
expect(result["a"]).toBe(1);
|
||||
expect(result["b"]).toBeUndefined();
|
||||
expect(result["c"]).toBe(3);
|
||||
});
|
||||
|
||||
it("map with complex values", { timeout: 5000 }, () => {
|
||||
const m = new Map<string, Date>([["now", new Date("2024-01-01")]]);
|
||||
const result = deserialize(serialize(m)) as Map<string, Date>;
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.get("now")).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serialize: error conditions", () => {
|
||||
it("circular reference throws CircularReferenceError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
obj["self"] = obj;
|
||||
expect(() => serialize(obj)).toThrowError(CircularReferenceError);
|
||||
});
|
||||
|
||||
it("max depth exceeded throws MaxDepthExceededError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const deep: Record<string, unknown> = {};
|
||||
let cur = deep;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
cur["child"] = {};
|
||||
cur = cur["child"] as Record<string, unknown>;
|
||||
}
|
||||
expect(() => serialize(deep, { maxDepth: 5 })).toThrowError(
|
||||
MaxDepthExceededError
|
||||
);
|
||||
});
|
||||
|
||||
it("max size exceeded throws MaxSizeExceededError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const obj = { data: "x".repeat(1000) };
|
||||
expect(() => serialize(obj, { maxSize: 10 })).toThrowError(
|
||||
MaxSizeExceededError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serialize: AbortSignal", () => {
|
||||
it("already-aborted signal throws immediately on first check", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error("cancelled"));
|
||||
// Build a structure large enough to trigger the check (>256 nodes)
|
||||
const arr = Array.from({ length: 300 }, (_, i) => i);
|
||||
expect(() => serialize(arr, { signal: controller.signal })).toThrow();
|
||||
});
|
||||
|
||||
it("non-aborted signal allows serialization", { timeout: 5000 }, () => {
|
||||
const controller = new AbortController();
|
||||
expect(() =>
|
||||
serialize({ a: 1 }, { signal: controller.signal })
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("serialize: custom registered type", () => {
|
||||
it("custom type round-trips", { timeout: 5000 }, () => {
|
||||
registerType({
|
||||
tag: CUSTOM_TAG,
|
||||
ctor: Point,
|
||||
encode: (v) => {
|
||||
const p = v as Point;
|
||||
return [p.x, p.y];
|
||||
},
|
||||
decode: (enc) => {
|
||||
const [x, y] = enc as [number, number];
|
||||
return new Point(x, y);
|
||||
},
|
||||
});
|
||||
|
||||
const p = new Point(3, 7);
|
||||
const result = deserialize(serialize(p)) as Point;
|
||||
expect(result).toBeInstanceOf(Point);
|
||||
expect(result.x).toBe(3);
|
||||
expect(result.y).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("serialize: BigInt encoding", () => {
|
||||
it("encodes BigInt as tagged value", { timeout: 5000 }, () => {
|
||||
const result = JSON.parse(serialize(42n));
|
||||
expect(result).toEqual({ __t: "__BigInt", v: "42" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("serialize: function and symbol are skipped", () => {
|
||||
it("function value in object is omitted (undefined return path)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Functions are not serializable — they return undefined from encodeValue,
|
||||
// which JSON.stringify omits from objects (same as native behavior).
|
||||
const obj: Record<string, unknown> = { a: 1, fn: () => 42 };
|
||||
const result = deserialize(serialize(obj)) as Record<string, unknown>;
|
||||
expect(result["a"]).toBe(1);
|
||||
expect("fn" in result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deserialize: unknown tag throws", () => {
|
||||
it("throws UnsupportedTypeError for unknown __t tag", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Craft a JSON string with an unknown tag directly
|
||||
const json = JSON.stringify({ __t: "__UnknownTag99999", v: null });
|
||||
expect(() => deserialize(json)).toThrowError(UnsupportedTypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deserialize: AbortSignal", () => {
|
||||
it("already-aborted signal throws during large deserialization", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build a large JSON with >256 nodes to trigger the signal check
|
||||
const arr = Array.from({ length: 300 }, (_, i) => i);
|
||||
const json = serialize(arr);
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error("cancelled"));
|
||||
expect(() =>
|
||||
deserialize(json, { signal: controller.signal })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("non-aborted signal allows deserialization", { timeout: 5000 }, () => {
|
||||
const controller = new AbortController();
|
||||
expect(() =>
|
||||
deserialize(serialize({ a: 1 }), { signal: controller.signal })
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
171
packages/foundation/data/serialize/src/codec/json/json-encode.ts
Normal file
171
packages/foundation/data/serialize/src/codec/json/json-encode.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
CircularReferenceError,
|
||||
InvalidOptionsError,
|
||||
MaxDepthExceededError,
|
||||
MaxSizeExceededError,
|
||||
SerializationError,
|
||||
} from "../../errors/serialize-error.js";
|
||||
import { getSerializeMetrics } from "../../observability/metrics.js";
|
||||
import { getTypeByCtor } from "../../registry/registry.js";
|
||||
|
||||
/** Options controlling the behavior of `serialize`. */
|
||||
export interface SerializeOptions {
|
||||
/** Maximum object graph depth before throwing `MaxDepthExceededError`. Default: 100. */
|
||||
maxDepth?: number;
|
||||
/** Maximum byte length of the JSON output before throwing `MaxSizeExceededError`. Default: unlimited. */
|
||||
maxSize?: number;
|
||||
/** An `AbortSignal` that cancels encoding mid-traversal. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const SIGNAL_CHECK_INTERVAL = 256;
|
||||
const ALLOWED_SERIALIZE_KEYS = new Set(["maxDepth", "maxSize", "signal"]);
|
||||
|
||||
function validateSerializeOptions(opts?: SerializeOptions): void {
|
||||
if (opts === undefined) return;
|
||||
for (const key of Object.keys(opts as object)) {
|
||||
if (!ALLOWED_SERIALIZE_KEYS.has(key)) {
|
||||
throw new InvalidOptionsError(`unknown serialize option: ${key}`);
|
||||
}
|
||||
}
|
||||
if (opts.maxDepth !== undefined && opts.maxDepth <= 0) {
|
||||
throw new InvalidOptionsError(
|
||||
`maxDepth must be > 0, got ${opts.maxDepth}`
|
||||
);
|
||||
}
|
||||
if (opts.maxSize !== undefined && opts.maxSize <= 0) {
|
||||
throw new InvalidOptionsError(
|
||||
`maxSize must be > 0, got ${opts.maxSize}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize any JavaScript value to a tagged JSON string that can be
|
||||
* reconstructed with `deserialize`.
|
||||
*
|
||||
* The encoder handles all registered types (built-in + custom), plus
|
||||
* `undefined`, `NaN`, and `±Infinity` which are normally lost by
|
||||
* `JSON.stringify`. Object cycles are detected via a `WeakSet` and throw
|
||||
* `CircularReferenceError`.
|
||||
*/
|
||||
export function serialize(value: unknown, opts?: SerializeOptions): string {
|
||||
validateSerializeOptions(opts);
|
||||
|
||||
const m = getSerializeMetrics();
|
||||
const start = Date.now();
|
||||
const maxDepth = opts?.maxDepth ?? 100;
|
||||
const maxSize = opts?.maxSize;
|
||||
const signal = opts?.signal;
|
||||
|
||||
const seen = new WeakSet<object>();
|
||||
let nodeCount = 0;
|
||||
|
||||
function encodeValue(val: unknown, depth: number, path: string): unknown {
|
||||
nodeCount++;
|
||||
|
||||
if (nodeCount % SIGNAL_CHECK_INTERVAL === 0) {
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
|
||||
if (depth > maxDepth) {
|
||||
throw new MaxDepthExceededError(depth);
|
||||
}
|
||||
|
||||
// Primitives: pass through
|
||||
if (val === null) return null;
|
||||
if (val === undefined) return { __t: "__undefined", v: null };
|
||||
if (typeof val === "boolean") return val;
|
||||
if (typeof val === "string") return val;
|
||||
|
||||
// Number: handle special IEEE 754 values
|
||||
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;
|
||||
}
|
||||
|
||||
// BigInt
|
||||
if (typeof val === "bigint") {
|
||||
return { __t: "__BigInt", v: val.toString() };
|
||||
}
|
||||
|
||||
// Object / reference types
|
||||
if (typeof val === "object") {
|
||||
// Cycle detection
|
||||
if (seen.has(val)) {
|
||||
throw new CircularReferenceError(path);
|
||||
}
|
||||
seen.add(val);
|
||||
|
||||
try {
|
||||
// Check for registered type by constructor
|
||||
const def = getTypeByCtor(
|
||||
(val as object).constructor as CallableFunction
|
||||
);
|
||||
if (def !== undefined) {
|
||||
const encoded = def.encode(val);
|
||||
return {
|
||||
__t: def.tag,
|
||||
v: encodeValue(encoded, depth + 1, `${path}.__encoded`),
|
||||
};
|
||||
}
|
||||
|
||||
// Array
|
||||
if (Array.isArray(val)) {
|
||||
return val.map((item, i) =>
|
||||
encodeValue(item, depth + 1, `${path}[${i}]`)
|
||||
);
|
||||
}
|
||||
|
||||
// Plain object — use null-prototype to avoid __proto__ pitfalls
|
||||
const result: Record<string, unknown> = Object.create(
|
||||
null
|
||||
) as Record<string, unknown>;
|
||||
for (const key of Object.keys(val as Record<string, unknown>)) {
|
||||
Object.defineProperty(result, key, {
|
||||
value: encodeValue(
|
||||
(val as Record<string, unknown>)[key],
|
||||
depth + 1,
|
||||
`${path}.${key}`
|
||||
),
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
seen.delete(val);
|
||||
}
|
||||
}
|
||||
|
||||
// function / symbol — not serializable, skip (return undefined → omitted from JSON)
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const encoded = encodeValue(value, 0, "root");
|
||||
const json = JSON.stringify(encoded);
|
||||
|
||||
if (maxSize !== undefined && json.length > maxSize) {
|
||||
throw new MaxSizeExceededError(json.length);
|
||||
}
|
||||
|
||||
m.encodeBytes.record(json.length, { format: "json" });
|
||||
m.payloadBytes.record(json.length, { format: "json" });
|
||||
m.encodeDuration.record(Date.now() - start, { format: "json" });
|
||||
|
||||
return json;
|
||||
} catch (err) {
|
||||
const code =
|
||||
err instanceof SerializationError
|
||||
? err.code
|
||||
: "SERIALIZE.CODEC.UNKNOWN";
|
||||
m.errorTotal.add(1, { code, format: "json" });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import z from "zod";
|
||||
|
||||
/** Zod schema for `SerializeOptions`. */
|
||||
export const SerializeOptionsSchema = z
|
||||
.object({
|
||||
maxDepth: z.number().int().positive().optional(),
|
||||
maxSize: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
/** Zod schema for `DeserializeOptions`. */
|
||||
export const DeserializeOptionsSchema = z.object({}).optional();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { decodeStream, encodeStream } from "./stream";
|
||||
@@ -0,0 +1,92 @@
|
||||
# stream
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides `encodeStream` and `decodeStream` for streaming serialization over environments that produce or consume `ReadableStream<Uint8Array>`. Rather than requiring the entire payload to be buffered before transmission, `encodeStream` chunks the MessagePack binary output into 4-byte-length-prefixed frames of up to 4 KB each, enabling progressive delivery over WebSockets, HTTP streaming responses, or other byte-stream transports.
|
||||
|
||||
`decodeStream` is the inverse: it reads framed chunks from a `ReadableStream<Uint8Array>`, reassembles the payload, and emits a single decoded value via a `ReadableStream<unknown>`.
|
||||
|
||||
## Features
|
||||
|
||||
- Frame format: 4-byte big-endian payload length + up to 4 KB payload
|
||||
- Streaming encode over `ReadableStream<Uint8Array>` using `encodeBinary` internally
|
||||
- Streaming decode that handles partial frames across multiple read chunks
|
||||
- `AbortSignal` support in both encode and decode paths
|
||||
- Errors propagated via `controller.error()` so consumers can catch via stream pipelines
|
||||
- Zero external deps beyond the msgpack codec and Web Streams API
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { decodeStream, encodeStream } from "@suaveplan/serialize";
|
||||
|
||||
const payload = { data: new Array(1000).fill("item") };
|
||||
const stream = encodeStream(payload);
|
||||
|
||||
// In the same process or after transmission:
|
||||
const decoded = decodeStream(stream);
|
||||
const reader = decoded.getReader();
|
||||
const { value } = await reader.read();
|
||||
// value deep-equals payload
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { decodeStream, encodeStream } from "@suaveplan/serialize";
|
||||
|
||||
// Abort if encoding takes too long
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(new Error("encode timeout")), 5000);
|
||||
|
||||
const stream = encodeStream(hugeObject, { signal: controller.signal });
|
||||
|
||||
// Pipe to a fetch body or WebSocket
|
||||
const response = new Response(stream, {
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
});
|
||||
|
||||
// On the receiving side:
|
||||
const incoming: ReadableStream<Uint8Array> = response.body!;
|
||||
const valueStream = decodeStream(incoming);
|
||||
const reader = valueStream.getReader();
|
||||
const { value } = await reader.read();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `encodeStream(value: unknown, opts?: SerializeOptions & { signal?: AbortSignal }): ReadableStream<Uint8Array>`
|
||||
|
||||
Serialize `value` using `encodeBinary` and emit the result as framed 4 KB chunks.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `value` — any serializable JavaScript value
|
||||
- `opts` — same as `SerializeOptions` plus optional `signal`
|
||||
|
||||
**Returns:** a `ReadableStream<Uint8Array>` where each chunk is a complete length-prefixed frame.
|
||||
|
||||
**Throws (via stream error):** all errors from `encodeBinary` plus `AbortSignal` reason.
|
||||
|
||||
### `decodeStream(stream: ReadableStream<Uint8Array>, opts?: DeserializeOptions): ReadableStream<unknown>`
|
||||
|
||||
Reassemble frames from `stream` and emit the decoded value.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `stream` — a framed stream produced by `encodeStream`
|
||||
- `opts` — `DeserializeOptions` including optional `signal`
|
||||
|
||||
**Returns:** a `ReadableStream<unknown>` that emits exactly one decoded value then closes.
|
||||
|
||||
**Throws (via stream error):** all errors from `decodeBinary` plus `AbortSignal` reason.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Each frame is self-delimiting (4-byte length prefix), so the receiver never needs to know the total payload size in advance.
|
||||
- `decodeStream` handles partial frames across multiple read iterations via an internal accumulator; frames that span chunk boundaries are correctly reassembled.
|
||||
- If the encoded payload is empty (e.g. for `null` or `undefined`), a single zero-payload frame is emitted so the stream does not close silently without producing any data.
|
||||
- The Web Streams API (`ReadableStream`) is used throughout, making the codec compatible with browser, Node.js (≥18), Bun, and Deno environments without polyfills.
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { decodeStream, encodeStream } from "./stream.js";
|
||||
|
||||
async function collectStream(
|
||||
stream: ReadableStream<Uint8Array>
|
||||
): Promise<Uint8Array[]> {
|
||||
const reader = stream.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value !== undefined) chunks.push(value);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
async function readSingleValue(
|
||||
stream: ReadableStream<unknown>
|
||||
): Promise<unknown> {
|
||||
const reader = stream.getReader();
|
||||
const { value } = await reader.read();
|
||||
reader.releaseLock();
|
||||
return value;
|
||||
}
|
||||
|
||||
describe("encodeStream / decodeStream", () => {
|
||||
it("small value encodes to a single chunk", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const payload = { hello: "world" };
|
||||
const stream = encodeStream(payload);
|
||||
const chunks = await collectStream(stream);
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("small value round-trips", { timeout: 30000 }, async () => {
|
||||
const payload = { hello: "world", count: 42 };
|
||||
const encoded = encodeStream(payload);
|
||||
const decoded = decodeStream(encoded);
|
||||
const result = await readSingleValue(decoded);
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
it("large value produces multiple chunks", { timeout: 30000 }, async () => {
|
||||
// Create payload large enough to exceed 4KB per chunk
|
||||
const payload = { data: "x".repeat(20_000) };
|
||||
const stream = encodeStream(payload);
|
||||
const chunks = await collectStream(stream);
|
||||
// Should produce multiple frames
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("large value round-trips faithfully", { timeout: 30000 }, async () => {
|
||||
const payload = { data: "x".repeat(20_000) };
|
||||
const encoded = encodeStream(payload);
|
||||
const decoded = decodeStream(encoded);
|
||||
const result = (await readSingleValue(decoded)) as typeof payload;
|
||||
expect(result.data).toHaveLength(20_000);
|
||||
expect(result.data[0]).toBe("x");
|
||||
});
|
||||
|
||||
it("Date round-trips through stream", { timeout: 30000 }, async () => {
|
||||
const payload = { created: new Date("2024-01-01T00:00:00.000Z") };
|
||||
const encoded = encodeStream(payload);
|
||||
const decoded = decodeStream(encoded);
|
||||
const result = (await readSingleValue(decoded)) as typeof payload;
|
||||
expect(result.created).toBeInstanceOf(Date);
|
||||
expect(result.created.toISOString()).toBe("2024-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("array round-trips through stream", { timeout: 30000 }, async () => {
|
||||
const payload = [1, "two", true, null];
|
||||
const encoded = encodeStream(payload);
|
||||
const decoded = decodeStream(encoded);
|
||||
const result = await readSingleValue(decoded);
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
it("null round-trips through stream", { timeout: 30000 }, async () => {
|
||||
const encoded = encodeStream(null);
|
||||
const decoded = decodeStream(encoded);
|
||||
const result = await readSingleValue(decoded);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("undefined round-trips through stream", { timeout: 30000 }, async () => {
|
||||
const encoded = encodeStream(undefined);
|
||||
const decoded = decodeStream(encoded);
|
||||
const result = await readSingleValue(decoded);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodeStream: AbortSignal", () => {
|
||||
it("already-aborted signal causes stream error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error("cancelled"));
|
||||
const stream = encodeStream(
|
||||
{ large: "x".repeat(100) },
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
await expect(async () => {
|
||||
await collectStream(stream);
|
||||
}).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("non-aborted signal allows encoding", { timeout: 30000 }, async () => {
|
||||
const controller = new AbortController();
|
||||
const stream = encodeStream({ a: 1 }, { signal: controller.signal });
|
||||
const chunks = await collectStream(stream);
|
||||
expect(chunks.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeStream: AbortSignal", () => {
|
||||
it("already-aborted signal causes decode stream error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const payload = { data: "x".repeat(50) };
|
||||
const encoded = encodeStream(payload);
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error("decode cancelled"));
|
||||
const decoded = decodeStream(encoded, { signal: controller.signal });
|
||||
|
||||
await expect(async () => {
|
||||
await readSingleValue(decoded);
|
||||
}).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("encodeStream: cancel", () => {
|
||||
it("cancelling the encoded stream does not throw", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const payload = { data: "x".repeat(20_000) };
|
||||
const stream = encodeStream(payload);
|
||||
const reader = stream.getReader();
|
||||
// Read one chunk then cancel
|
||||
await reader.read();
|
||||
await reader.cancel();
|
||||
// No error should be thrown
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeStream: edge cases", () => {
|
||||
it("handles frame bytes arriving one at a time (partial header/payload)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Build a framed payload and deliver it byte-by-byte to ensure
|
||||
// the decoder correctly reassembles frames across read() calls.
|
||||
const payload = { greeting: "hello from the stream" };
|
||||
const encoded = encodeStream(payload);
|
||||
const chunks = await collectStream(encoded);
|
||||
|
||||
// Flatten all frame bytes
|
||||
const allBytes = new Uint8Array(
|
||||
chunks.reduce((sum, c) => sum + c.byteLength, 0)
|
||||
);
|
||||
let off = 0;
|
||||
for (const c of chunks) {
|
||||
allBytes.set(c, off);
|
||||
off += c.byteLength;
|
||||
}
|
||||
|
||||
// Stream that emits one byte at a time
|
||||
const tinyStream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (let i = 0; i < allBytes.byteLength; i++) {
|
||||
controller.enqueue(allBytes.slice(i, i + 1));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const decoded = decodeStream(tinyStream);
|
||||
const result = await readSingleValue(decoded);
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
});
|
||||
163
packages/foundation/data/serialize/src/codec/stream/stream.ts
Normal file
163
packages/foundation/data/serialize/src/codec/stream/stream.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { DeserializeOptions } from "../json/json-decode.js";
|
||||
import type { SerializeOptions } from "../json/json-encode.js";
|
||||
import { decodeBinary } from "../msgpack/msgpack-decode.js";
|
||||
import { encodeBinary } from "../msgpack/msgpack-encode.js";
|
||||
|
||||
/** Maximum bytes per stream frame (4 KB). */
|
||||
const MAX_FRAME_SIZE = 4096;
|
||||
|
||||
/**
|
||||
* Write a 4-byte big-endian length prefix into `buffer` starting at `offset`.
|
||||
*/
|
||||
function writeUint32BE(
|
||||
buffer: Uint8Array,
|
||||
offset: number,
|
||||
value: number
|
||||
): void {
|
||||
buffer[offset] = (value >>> 24) & 0xff;
|
||||
buffer[offset + 1] = (value >>> 16) & 0xff;
|
||||
buffer[offset + 2] = (value >>> 8) & 0xff;
|
||||
buffer[offset + 3] = value & 0xff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a 4-byte big-endian unsigned integer from `buffer` starting at `offset`.
|
||||
*/
|
||||
function readUint32BE(buffer: Uint8Array, offset: number): number {
|
||||
return (
|
||||
((buffer[offset]! << 24) |
|
||||
(buffer[offset + 1]! << 16) |
|
||||
(buffer[offset + 2]! << 8) |
|
||||
buffer[offset + 3]!) >>>
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize `value` to MessagePack and return a `ReadableStream<Uint8Array>`
|
||||
* that emits framed chunks. Each frame is a 4-byte big-endian length prefix
|
||||
* followed by up to `MAX_FRAME_SIZE` (4 KB) payload bytes.
|
||||
*
|
||||
* The stream honours the provided `AbortSignal` and emits an error if
|
||||
* cancelled before all chunks are yielded.
|
||||
*/
|
||||
export function encodeStream(
|
||||
value: unknown,
|
||||
opts?: SerializeOptions & { signal?: AbortSignal }
|
||||
): ReadableStream<Uint8Array> {
|
||||
const signal = opts?.signal;
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
try {
|
||||
if (signal !== undefined) signal.throwIfAborted();
|
||||
|
||||
const bytes = encodeBinary(value, opts);
|
||||
const totalBytes = bytes.byteLength;
|
||||
|
||||
let offset = 0;
|
||||
while (offset < totalBytes) {
|
||||
if (signal !== undefined) signal.throwIfAborted();
|
||||
|
||||
const chunkSize = Math.min(
|
||||
MAX_FRAME_SIZE,
|
||||
totalBytes - offset
|
||||
);
|
||||
const chunk = bytes.slice(offset, offset + chunkSize);
|
||||
|
||||
// Build framed output: [length:4][payload:chunkSize]
|
||||
const frame = new Uint8Array(4 + chunkSize);
|
||||
writeUint32BE(frame, 0, chunkSize);
|
||||
frame.set(chunk, 4);
|
||||
|
||||
controller.enqueue(frame);
|
||||
offset += chunkSize;
|
||||
}
|
||||
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
controller.error(err);
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
// Stream cancelled by consumer — nothing to clean up.
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassemble framed chunks from `stream` (produced by `encodeStream`) and
|
||||
* return a `ReadableStream<unknown>` that emits a single decoded value once
|
||||
* all frames have been received.
|
||||
*
|
||||
* Each frame must be a complete 4-byte-length-prefixed chunk — partial
|
||||
* frames across multiple reads are handled via an internal accumulator.
|
||||
*/
|
||||
export function decodeStream(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
opts?: DeserializeOptions
|
||||
): ReadableStream<unknown> {
|
||||
const signal = opts?.signal;
|
||||
|
||||
return new ReadableStream<unknown>({
|
||||
async start(controller) {
|
||||
const reader = stream.getReader();
|
||||
// Accumulate all raw bytes (header + payload) from the stream.
|
||||
const rawChunks: Uint8Array[] = [];
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal !== undefined) signal.throwIfAborted();
|
||||
|
||||
const result = await reader.read();
|
||||
|
||||
if (result.done) break;
|
||||
// When done is false, value is always defined per ReadableStream spec
|
||||
rawChunks.push(result.value as Uint8Array);
|
||||
}
|
||||
|
||||
// Concatenate all received bytes into one buffer.
|
||||
const totalRaw = rawChunks.reduce(
|
||||
(sum, c) => sum + c.byteLength,
|
||||
0
|
||||
);
|
||||
const raw = new Uint8Array(totalRaw);
|
||||
let writePos = 0;
|
||||
for (const c of rawChunks) {
|
||||
raw.set(c, writePos);
|
||||
writePos += c.byteLength;
|
||||
}
|
||||
|
||||
// Parse frames: each is [4-byte BE length][payload bytes].
|
||||
const payloadChunks: Uint8Array[] = [];
|
||||
let pos = 0;
|
||||
while (pos + 4 <= raw.byteLength) {
|
||||
const payloadLen = readUint32BE(raw, pos);
|
||||
pos += 4;
|
||||
payloadChunks.push(raw.slice(pos, pos + payloadLen));
|
||||
pos += payloadLen;
|
||||
}
|
||||
|
||||
// Reassemble all payload chunks into one msgpack buffer.
|
||||
const totalPayload = payloadChunks.reduce(
|
||||
(sum, c) => sum + c.byteLength,
|
||||
0
|
||||
);
|
||||
const assembled = new Uint8Array(totalPayload);
|
||||
let assemblePos = 0;
|
||||
for (const c of payloadChunks) {
|
||||
assembled.set(c, assemblePos);
|
||||
assemblePos += c.byteLength;
|
||||
}
|
||||
|
||||
const value = decodeBinary(assembled, opts);
|
||||
controller.enqueue(value);
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
controller.error(err);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# wire.schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the Zod schemas that pin the **wire-format invariants** of the serialize codecs. Both the JSON codec and the MessagePack codec produce structurally identical intermediate values: a recursive tree of JSON-compatible primitives, arrays, plain records, and tagged envelopes (`{ "__t": "<tag>", "v": <encoded> }`). This module defends the public `deserialize` and `decodeBinary` boundaries against accidental or malicious inputs that would otherwise reach the type registry's `decode` functions in an unexpected shape — for example a number where an envelope is expected, or an envelope with `__t` set to a non-string value.
|
||||
|
||||
The schemas are exported as both reusable values (`WireNodeSchema`, `TaggedEnvelopeSchema`) and an ergonomic helper (`validateWireNode`) that wraps `safeParse` and exposes the parse outcome as a discriminated union so call sites can wrap rejection in a `MalformedWireError` with stable code metadata.
|
||||
|
||||
## Features
|
||||
|
||||
- Recursive `WireNodeSchema` covering primitives, arrays, tagged envelopes, and plain records
|
||||
- Strict `TaggedEnvelopeSchema` that rejects extra keys
|
||||
- `validateWireNode` helper returning `{ ok: true; value } | { ok: false; error }`
|
||||
- Used by `deserialize` (JSON) and `decodeBinary` (MessagePack) at the public boundary
|
||||
- Pure, side-effect free; tree-shakes when unused
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { validateWireNode } from "@suaveplan/serialize";
|
||||
|
||||
const result = validateWireNode({ __t: "__Date", v: "2024-01-01T00:00:00.000Z" });
|
||||
if (result.ok) {
|
||||
// safe to feed to the type-registry decode pipeline
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MalformedWireError, validateWireNode } from "@suaveplan/serialize";
|
||||
|
||||
function strictDeserialize(input: unknown): unknown {
|
||||
const wire = validateWireNode(input);
|
||||
if (!wire.ok) {
|
||||
throw new MalformedWireError(
|
||||
"input does not match the tagged-wire schema",
|
||||
"root",
|
||||
wire.error
|
||||
);
|
||||
}
|
||||
return wire.value;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `WireNodeSchema: z.ZodType<unknown>` — recursive schema for any wire-format node.
|
||||
- `TaggedEnvelopeSchema: z.ZodObject<{ __t: ZodString, v: ZodUnknown }>` — strict envelope schema.
|
||||
- `validateWireNode(value: unknown): { ok: true; value: unknown } | { ok: false; error: ZodError }` — non-throwing validator.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`WireNodeSchema` is defined via `z.lazy()` to support recursion through arrays and records. The envelope schema uses `.strict()` so an envelope with a smuggled-in third key is rejected — this matches the `serialize`/`encodeBinary` output, which only ever emits `__t` + `v`. We intentionally exclude `BigInt`, `Map`, `Set`, and other JS-only constructs from the schema: the wire format never produces them at this layer (BigInts arrive as a `__BigInt` envelope with a string `v`), so admitting them would weaken the invariant.
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Spec-conformance tests for SZ-10 — Zod schema validation at the public
|
||||
* boundary. Pins:
|
||||
* - WireNodeSchema accepts every valid wire shape produced by serialize.
|
||||
* - WireNodeSchema rejects malformed envelopes (non-string __t, extra keys).
|
||||
* - deserialize rejects malformed JSON wire input with MalformedWireError.
|
||||
* - decodeBinary rejects malformed msgpack wire input with MalformedWireError.
|
||||
* - serialize / deserialize / decodeBinary reject invalid options with
|
||||
* InvalidOptionsError (typed by stable error code).
|
||||
*/
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
InvalidOptionsError,
|
||||
MalformedWireError,
|
||||
} from "../../errors/serialize-error.js";
|
||||
import { registerType, unregisterType } from "../../registry/registry.js";
|
||||
import { deserialize } from "../json/json-decode.js";
|
||||
import { serialize } from "../json/json-encode.js";
|
||||
import { decodeBinary } from "../msgpack/msgpack-decode.js";
|
||||
import { encodeBinary } from "../msgpack/msgpack-encode.js";
|
||||
import {
|
||||
TaggedEnvelopeSchema,
|
||||
validateWireNode,
|
||||
WireNodeSchema,
|
||||
} from "./wire.schemas.js";
|
||||
|
||||
describe("SZ-10 — WireNodeSchema acceptance", () => {
|
||||
it("accepts JSON primitives", { timeout: 5000 }, () => {
|
||||
for (const v of [null, true, false, 1, "x"]) {
|
||||
expect(WireNodeSchema.safeParse(v).success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts arrays of nodes", { timeout: 5000 }, () => {
|
||||
expect(WireNodeSchema.safeParse([1, "x", null]).success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts plain records", { timeout: 5000 }, () => {
|
||||
expect(WireNodeSchema.safeParse({ a: 1, b: "x" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a tagged envelope", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
WireNodeSchema.safeParse({ __t: "__Date", v: "2024-01-01" }).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SZ-10 — WireNodeSchema rejection", () => {
|
||||
it("rejects undefined", { timeout: 5000 }, () => {
|
||||
expect(WireNodeSchema.safeParse(undefined).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects symbols and functions", { timeout: 5000 }, () => {
|
||||
expect(WireNodeSchema.safeParse(Symbol("x")).success).toBe(false);
|
||||
expect(WireNodeSchema.safeParse(() => 0).success).toBe(false);
|
||||
});
|
||||
|
||||
it("strict envelope rejects extra keys", { timeout: 5000 }, () => {
|
||||
const r = TaggedEnvelopeSchema.safeParse({
|
||||
__t: "__Date",
|
||||
v: "x",
|
||||
extra: 1,
|
||||
});
|
||||
expect(r.success).toBe(false);
|
||||
});
|
||||
|
||||
it("validateWireNode returns ZodError on rejection", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = validateWireNode(undefined);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) {
|
||||
expect(r.error.issues.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SZ-10 — deserialize boundary validation", () => {
|
||||
it("invalid JSON throws MalformedWireError", { timeout: 5000 }, () => {
|
||||
expect(() => deserialize("{not json")).toThrowError(MalformedWireError);
|
||||
});
|
||||
|
||||
it("malformed envelope (non-string __t) throws MalformedWireError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bad = JSON.stringify({ __t: 42, v: null });
|
||||
expect(() => deserialize(bad)).toThrowError(MalformedWireError);
|
||||
});
|
||||
|
||||
it("MalformedWireError carries CODEC.MALFORMED_WIRE code", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
deserialize("{not json");
|
||||
} catch (e) {
|
||||
expect((e as MalformedWireError).code).toBe(
|
||||
"SERIALIZE.CODEC.MALFORMED_WIRE"
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected throw");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SZ-10 — decodeBinary boundary validation", () => {
|
||||
it("invalid msgpack bytes throw MalformedWireError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// 0xc1 is reserved/never-used in msgpack and triggers a decode error.
|
||||
const bad = new Uint8Array([0xc1, 0xc1, 0xc1]);
|
||||
expect(() => decodeBinary(bad)).toThrowError(MalformedWireError);
|
||||
});
|
||||
|
||||
it("round-trips valid msgpack input", { timeout: 5000 }, () => {
|
||||
const bytes = encodeBinary({ a: 1, b: "two" });
|
||||
const back = decodeBinary(bytes);
|
||||
expect(back).toEqual({ a: 1, b: "two" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("SZ-10 — invalid options rejection", () => {
|
||||
it("serialize throws InvalidOptionsError for non-positive maxDepth", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => serialize({}, { maxDepth: -1 } as never)).toThrowError(
|
||||
InvalidOptionsError
|
||||
);
|
||||
});
|
||||
|
||||
it("serialize throws InvalidOptionsError for non-positive maxSize", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => serialize({}, { maxSize: 0 } as never)).toThrowError(
|
||||
InvalidOptionsError
|
||||
);
|
||||
});
|
||||
|
||||
it("serialize throws InvalidOptionsError for unknown option keys", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => serialize({}, { unknown: 1 } as never)).toThrowError(
|
||||
InvalidOptionsError
|
||||
);
|
||||
});
|
||||
|
||||
it("deserialize throws InvalidOptionsError for non-AbortSignal signal", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
deserialize("null", { signal: "nope" as unknown as AbortSignal })
|
||||
).toThrowError(InvalidOptionsError);
|
||||
});
|
||||
|
||||
it("encodeBinary throws InvalidOptionsError for non-positive maxSize", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => encodeBinary({}, { maxSize: -10 } as never)).toThrowError(
|
||||
InvalidOptionsError
|
||||
);
|
||||
});
|
||||
|
||||
it("decodeBinary throws InvalidOptionsError for unknown option keys", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bytes = encodeBinary({ a: 1 });
|
||||
expect(() => decodeBinary(bytes, { unknown: 1 } as never)).toThrowError(
|
||||
InvalidOptionsError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SZ-10 — error class branch coverage", () => {
|
||||
it("MalformedWireError works without a path argument", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new MalformedWireError("no-path");
|
||||
expect(err.code).toBe("SERIALIZE.CODEC.MALFORMED_WIRE");
|
||||
expect(err.context?.["tag"]).toBe("no-path");
|
||||
expect(err.context?.["path"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("InvalidOptionsError works with cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new InvalidOptionsError("invalid", cause);
|
||||
expect(err.code).toBe("SERIALIZE.OPTIONS.INVALID");
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SZ-10 — msgpack wire-schema rejection", () => {
|
||||
it("decodeBinary rejects msgpack-encoded malformed envelopes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build a real msgpack buffer for { __t: 1, v: 1 } using @msgpack/msgpack
|
||||
// directly — bypassing our encoder so the wire-schema rejection path
|
||||
// can be exercised independently of JSON.parse.
|
||||
// 0x82 = fixmap of 2; 0xa3 "__t" = fixstr; 0x01 = 1; 0xa1 "v" = fixstr; 0x01 = 1.
|
||||
const bytes = new Uint8Array([
|
||||
0x82, 0xa3, 0x5f, 0x5f, 0x74, 0x01, 0xa1, 0x76, 0x01,
|
||||
]);
|
||||
expect(() => decodeBinary(bytes)).toThrowError(MalformedWireError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SZ-10 — encode/decode error label fallback", () => {
|
||||
it("encode error path tags non-Error throws as SERIALIZE.CODEC.UNKNOWN", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Register a custom encode function that throws a non-Error value to
|
||||
// exercise the `: "SERIALIZE.CODEC.UNKNOWN"` branch in both codecs.
|
||||
const tag = "__SerializeEncodeBranchProbe";
|
||||
const ctor = class EncodeBranchProbe {};
|
||||
registerType({
|
||||
tag,
|
||||
ctor,
|
||||
encode: () => {
|
||||
throw "raw-string-throw";
|
||||
},
|
||||
decode: (v) => v,
|
||||
});
|
||||
try {
|
||||
const probe = new ctor();
|
||||
expect(() => serialize(probe)).toThrow();
|
||||
expect(() => encodeBinary(probe)).toThrow();
|
||||
} finally {
|
||||
unregisterType(tag);
|
||||
}
|
||||
});
|
||||
|
||||
it("decode error path tags non-Error throws as SERIALIZE.CODEC.UNKNOWN", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Register a custom decode function that throws a non-Error value to
|
||||
// exercise the `: "SERIALIZE.CODEC.UNKNOWN"` branch on the decode side.
|
||||
const tag = "__SerializeDecodeBranchProbe";
|
||||
const ctor = class DecodeBranchProbe {};
|
||||
registerType({
|
||||
tag,
|
||||
ctor,
|
||||
encode: () => null,
|
||||
decode: () => {
|
||||
throw "raw-string-throw";
|
||||
},
|
||||
});
|
||||
try {
|
||||
const probe = new ctor();
|
||||
const json = serialize(probe);
|
||||
expect(() => deserialize(json)).toThrow();
|
||||
const bytes = encodeBinary(probe);
|
||||
expect(() => decodeBinary(bytes)).toThrow();
|
||||
} finally {
|
||||
unregisterType(tag);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("SZ-10 — generic threading", () => {
|
||||
it("deserialize<T>() returns T at the type level", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const json = serialize({ name: "ada", age: 36 });
|
||||
const result = deserialize<{ name: string; age: number }>(json);
|
||||
expectTypeOf(result).toEqualTypeOf<{ name: string; age: number }>();
|
||||
expect(result.name).toBe("ada");
|
||||
});
|
||||
|
||||
it("decodeBinary<T>() returns T at the type level", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bytes = encodeBinary([1, 2, 3]);
|
||||
const result = decodeBinary<number[]>(bytes);
|
||||
expectTypeOf(result).toEqualTypeOf<number[]>();
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("deserialize() defaults to unknown when T is omitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = deserialize(serialize({ a: 1 }));
|
||||
expectTypeOf(result).toEqualTypeOf<unknown>();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema describing the structural envelope of a tagged value in the
|
||||
* serialize wire format: `{ "__t": "<tag>", "v": <encoded> }`.
|
||||
*
|
||||
* The `__t` field MUST be a non-empty string and the `v` field MUST be
|
||||
* present (even if encoded as `null`). Extra keys are forbidden — a tagged
|
||||
* envelope that smuggles in a third key is by definition malformed and is
|
||||
* rejected at the public boundary.
|
||||
*/
|
||||
export const TaggedEnvelopeSchema = z
|
||||
.object({
|
||||
__t: z.string().min(1),
|
||||
v: z.unknown(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Refinement applied to plain records: if a record has a `__t` key it MUST
|
||||
* also conform to the strict envelope shape (string `__t`, present `v`,
|
||||
* no extra keys). Otherwise the record is malformed wire input. Records
|
||||
* without `__t` are passed through untouched — user data is allowed any
|
||||
* string-keyed shape that JSON.parse / msgpack can produce.
|
||||
*/
|
||||
function recordIsWellFormed(record: Record<string, unknown>): boolean {
|
||||
if (!("__t" in record)) {
|
||||
return true;
|
||||
}
|
||||
return TaggedEnvelopeSchema.safeParse(record).success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod schema describing one node of the serialize wire format. The shape is
|
||||
* recursive: any node MAY be a JSON primitive, an array of nodes, a tagged
|
||||
* envelope, or a plain string-keyed record of nodes (a record with a `__t`
|
||||
* key MUST conform to the envelope shape — no extra keys, `__t` must be a
|
||||
* non-empty string, `v` must be present).
|
||||
*
|
||||
* Inputs that contain JS-only constructs the wire format never produces
|
||||
* (e.g. `undefined`, functions, symbols, raw BigInts) are rejected. The
|
||||
* intent is to defend the deserialize / decodeBinary boundary against
|
||||
* accidental or malicious inputs that would otherwise reach the type
|
||||
* registry's decode functions in an unexpected shape.
|
||||
*/
|
||||
export const WireNodeSchema: z.ZodType<unknown> = z.lazy(() =>
|
||||
z.union([
|
||||
z.null(),
|
||||
z.boolean(),
|
||||
z.number(),
|
||||
z.string(),
|
||||
z.array(WireNodeSchema),
|
||||
z.record(z.string(), WireNodeSchema).refine(recordIsWellFormed, {
|
||||
message:
|
||||
"record with __t key must conform to the tagged envelope schema (string __t, present v, no extra keys)",
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
/**
|
||||
* Validate a parsed-but-not-yet-decoded wire value against the structural
|
||||
* invariants of the serialize wire format. Returns the input cast to
|
||||
* `unknown` if valid; otherwise returns the underlying ZodError so the
|
||||
* caller can wrap it in a `MalformedWireError` with stable code metadata.
|
||||
*/
|
||||
export function validateWireNode(
|
||||
value: unknown
|
||||
): { ok: true; value: unknown } | { ok: false; error: z.ZodError } {
|
||||
const parsed = WireNodeSchema.safeParse(value);
|
||||
if (parsed.success) {
|
||||
return { ok: true, value: parsed.data };
|
||||
}
|
||||
return { ok: false, error: parsed.error };
|
||||
}
|
||||
Reference in New Issue
Block a user