Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
# @suaveplan/serialize - Round-trip-preserving structured serialization — tagged JSON, MessagePack binary, streaming, eval-safe by default
## Summary
Round-trip-preserving structured serialization for JavaScript's rich type system. The standard `JSON.stringify`/`JSON.parse` pair silently drops `Date` objects, `Map`, `Set`, `BigInt`, `Uint8Array`, `URL`, `RegExp`, `undefined`, `NaN`, and `±Infinity`. This package preserves all of them via a tagged-JSON codec (`serialize`/`deserialize`) and an efficient MessagePack binary codec (`encodeBinary`/`decodeBinary`). It also provides 4-byte-framed streaming codecs for `ReadableStream` pipelines, cycle detection with path context, a pluggable type registry for custom classes, and an opt-in `/unsafe` subpath that handles `Function`-bearing payloads. The default entry point is eval-safe. Use `@suaveplan/serialize` whenever you need to serialise application state across process boundaries, persist structured data, or stream binary payloads — and want to get back exactly what you put in.
<!-- begin:auto-docs -->
## Documentation tree
- [eval-safe](./src/adapters/eval-safe/eval-safe.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [eval-safe](./src/adapters/eval-safe/eval-safe.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [json-decode](./src/codec/json/json-decode.md) — Implements `deserialize`, the inverse of `serialize`. Given a tagged JSON string produced by the encoder, it reconstruc…
- [json-encode](./src/codec/json/json-encode.md) — Implements the `serialize` function that converts any JavaScript value to a tagged JSON string preserving type informat…
- [msgpack-decode](./src/codec/msgpack/msgpack-decode.md) — Implements `decodeBinary`, the inverse of `encodeBinary`. Given a MessagePack binary buffer produced by the encoder, it…
- [msgpack-encode](./src/codec/msgpack/msgpack-encode.md) — Implements `encodeBinary`, the MessagePack counterpart to `serialize`. It converts any JavaScript value to a compact bi…
- [stream](./src/codec/stream/stream.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [stream](./src/codec/stream/stream.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [wire](./src/codec/wire/wire.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [wire.schemas](./src/codec/wire/wire.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes/error-codes.md) — `error-codes` is the public re-export surface for the serialization error code registry. It imports `serialize-error` a…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes` is the public re-export surface for the serialization error code registry. It imports `serialize-error` a…
- [codes](./src/errors/codes.md) — `codes` defines the canonical set of error codes for the `@suaveplan/serialize` package using `defineCodes` from `@suav…
- [serialize-error](./src/errors/serialize-error.md) — Defines the error hierarchy for `@suaveplan/serialize`. All errors thrown by this package extend `SerializationError`,…
- [legacy](./src/legacy/legacy.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [legacy](./src/legacy/legacy.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [eval-safe](./src/adapters/eval-safe/eval-safe.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-decode](./src/codec/json/json-decode.md) — Implements `deserialize`, the inverse of `serialize`. Given a tagged JSON string produced by the encoder, it reconstruc…
- [json-encode](./src/codec/json/json-encode.md) — Implements the `serialize` function that converts any JavaScript value to a tagged JSON string preserving type informat…
- [msgpack-decode](./src/codec/msgpack/msgpack-decode.md) — Implements `decodeBinary`, the inverse of `encodeBinary`. Given a MessagePack binary buffer produced by the encoder, it…
- [msgpack-encode](./src/codec/msgpack/msgpack-encode.md) — Implements `encodeBinary`, the MessagePack counterpart to `serialize`. It converts any JavaScript value to a compact bi…
- [stream](./src/codec/stream/stream.md) — <!-- begin:auto-docs --> _(no submodules)_- [stream](./src/codec/stream/stream.md) — <!-- begin:auto-docs --> _(no submodules)_- [wire](./src/codec/wire/wire.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [wire.schemas](./src/codec/wire/wire.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `codes` defines the canonical set of error codes for the `@suaveplan/serialize` package using `defineCodes` from `@suav…
- [serialize-error](./src/errors/serialize-error.md) — Defines the error hierarchy for `@suaveplan/serialize`. All errors thrown by this package extend `SerializationError`,…
- [legacy](./src/legacy/legacy.md) — <!-- begin:auto-docs --> _(no submodules)_- [legacy](./src/legacy/legacy.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — `error-codes` is the public re-export surface for the serialization error code registry. It imports `serialize-error` a…
- [eval-safe](./src/adapters/eval-safe/eval-safe.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-decode](./src/codec/json/json-decode.md) — Implements `deserialize`, the inverse of `serialize`. Given a tagged JSON string produced by the encoder, it reconstruc…
- [json-encode](./src/codec/json/json-encode.md) — Implements the `serialize` function that converts any JavaScript value to a tagged JSON string preserving type informat…
- [msgpack-decode](./src/codec/msgpack/msgpack-decode.md) — Implements `decodeBinary`, the inverse of `encodeBinary`. Given a MessagePack binary buffer produced by the encoder, it…
- [msgpack-encode](./src/codec/msgpack/msgpack-encode.md) — Implements `encodeBinary`, the MessagePack counterpart to `serialize`. It converts any JavaScript value to a compact bi…
- [stream](./src/codec/stream/stream.md) — <!-- begin:auto-docs --> _(no submodules)_- [stream](./src/codec/stream/stream.md) — <!-- begin:auto-docs --> _(no submodules)_- [wire](./src/codec/wire/wire.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [wire.schemas](./src/codec/wire/wire.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `codes` defines the canonical set of error codes for the `@suaveplan/serialize` package using `defineCodes` from `@suav…
- [serialize-error](./src/errors/serialize-error.md) — Defines the error hierarchy for `@suaveplan/serialize`. All errors thrown by this package extend `SerializationError`,…
- [legacy](./src/legacy/legacy.md) — <!-- begin:auto-docs --> _(no submodules)_- [legacy](./src/legacy/legacy.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — `error-codes` is the public re-export surface for the serialization error code registry. It imports `serialize-error` a…
- [eval-safe](./src/adapters/eval-safe/eval-safe.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-decode](./src/codec/json/json-decode.md) — Implements `deserialize`, the inverse of `serialize`. Given a tagged JSON string produced by the encoder, it reconstruc…
- [json-encode](./src/codec/json/json-encode.md) — Implements the `serialize` function that converts any JavaScript value to a tagged JSON string preserving type informat…
- [msgpack-decode](./src/codec/msgpack/msgpack-decode.md) — Implements `decodeBinary`, the inverse of `encodeBinary`. Given a MessagePack binary buffer produced by the encoder, it…
- [msgpack-encode](./src/codec/msgpack/msgpack-encode.md) — Implements `encodeBinary`, the MessagePack counterpart to `serialize`. It converts any JavaScript value to a compact bi…
- [stream](./src/codec/stream/stream.md) — <!-- begin:auto-docs --> _(no submodules)_- [stream](./src/codec/stream/stream.md) — <!-- begin:auto-docs --> _(no submodules)_- [wire](./src/codec/wire/wire.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [wire.schemas](./src/codec/wire/wire.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `codes` defines the canonical set of error codes for the `@suaveplan/serialize` package using `defineCodes` from `@suav…
- [serialize-error](./src/errors/serialize-error.md) — Defines the error hierarchy for `@suaveplan/serialize`. All errors thrown by this package extend `SerializationError`,…
- [legacy](./src/legacy/legacy.md) — <!-- begin:auto-docs --> _(no submodules)_- [legacy](./src/legacy/legacy.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — `error-codes` is the public re-export surface for the serialization error code registry. It imports `serialize-error` a…
- [eval-safe](./src/adapters/eval-safe/eval-safe.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-decode](./src/codec/json/json-decode.md) — Implements `deserialize`, the inverse of `serialize`. Given a tagged JSON string produced by the encoder, it reconstruc…
- [json-encode](./src/codec/json/json-encode.md) — Implements the `serialize` function that converts any JavaScript value to a tagged JSON string preserving type informat…
- [msgpack-decode](./src/codec/msgpack/msgpack-decode.md) — Implements `decodeBinary`, the inverse of `encodeBinary`. Given a MessagePack binary buffer produced by the encoder, it…
- [msgpack-encode](./src/codec/msgpack/msgpack-encode.md) — Implements `encodeBinary`, the MessagePack counterpart to `serialize`. It converts any JavaScript value to a compact bi…
- [stream](./src/codec/stream/stream.md) — <!-- begin:auto-docs --> _(no submodules)_ - [stream](./src/codec/stream/stream.md) — <!-- begin:auto-docs --> _(no submodules)_- [wire](./src/codec/wire/wire.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [wire.schemas](./src/codec/wire/wire.schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [codes](./src/errors/codes.md) — `codes` defines the canonical set of error codes for the `@suaveplan/serialize` package using `defineCodes` from `@suav…
- [serialize-error](./src/errors/serialize-error.md) — Defines the error hierarchy for `@suaveplan/serialize`. All errors thrown by this package extend `SerializationError`,…
- [legacy](./src/legacy/legacy.md) — <!-- begin:auto-docs --> _(no submodules)_ - [legacy](./src/legacy/legacy.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_ - [registry](./src/registry/registry.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — `error-codes` is the public re-export surface for the serialization error code registry. It imports `serialize-error` a…
## Features
- **Tagged JSON** — `serialize`/`deserialize` preserve `Date`, `Map`, `Set`, `BigInt`, all TypedArrays, `URL`, `RegExp`, `undefined`, `NaN`, `±Infinity`
- **MessagePack binary** — `encodeBinary`/`decodeBinary` produce compact binary with canonical key ordering
- **Streaming** — `encodeStream`/`decodeStream` via 4-byte-framed `ReadableStream<Uint8Array>`
- **Custom type registry** — `registerType` adds arbitrary classes with encode/decode hooks
- **Eval-safe adapter** — `@suaveplan/serialize/unsafe` for Function-including payloads (opt-in only)
- **AbortSignal support** on all codec paths
- **Cycle detection** — `CircularReferenceError` with path context
## Quick Start
```typescript
import { deserialize, serialize } from "@suaveplan/serialize";
const obj = {
created: new Date(),
tags: new Set(["a", "b"]),
count: BigInt(9007199254740993),
};
const json = serialize(obj);
const restored = deserialize(json);
// restored.created instanceof Date → true
// restored.tags instanceof Set → true
// restored.count === 9007199254740993n → true
```
## Modules
| Module | Purpose |
|--------|---------|
| [`src/codec/json/json-encode.md`](src/codec/json/json-encode.md) | Tagged JSON encoder |
| [`src/codec/json/json-decode.md`](src/codec/json/json-decode.md) | Tagged JSON decoder |
| [`src/codec/msgpack/msgpack-encode.md`](src/codec/msgpack/msgpack-encode.md) | MessagePack encoder |
| [`src/codec/msgpack/msgpack-decode.md`](src/codec/msgpack/msgpack-decode.md) | MessagePack decoder |
| [`src/codec/stream/stream.md`](src/codec/stream/stream.md) | Streaming framed codec |
| [`src/registry/registry.md`](src/registry/registry.md) | Type registry |
| [`src/errors/serialize-error.md`](src/errors/serialize-error.md) | Error hierarchy |
| [`src/legacy/legacy.md`](src/legacy/legacy.md) | Legacy Date-only adapter |
| [`src/adapters/eval-safe/eval-safe.md`](src/adapters/eval-safe/eval-safe.md) | Eval-safe adapter (unsafe subpath) |
## API
See co-located `.md` files for each module's full API reference.
## Subpath Exports
```typescript
import { serialize, deserialize } from "@suaveplan/serialize"; // main
import { serializeEvalSafe } from "@suaveplan/serialize/unsafe"; // eval-capable (opt-in)
```

View File

@@ -0,0 +1,188 @@
{
"name": "@suaveplan/serialize",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Round-trip-preserving structured serialization — tagged JSON, MessagePack binary, streaming, eval-safe by default",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./unsafe": {
"import": {
"types": "./dist/adapters/index.d.ts",
"default": "./dist/adapters/index.js"
},
"require": {
"types": "./dist/adapters/index.d.ts",
"default": "./dist/adapters/index.js"
}
},
"./adapters/eval-safe/eval-safe": {
"import": {
"types": "./dist/adapters/eval-safe/eval-safe.d.ts",
"default": "./dist/adapters/eval-safe/eval-safe.js"
},
"require": {
"types": "./dist/adapters/eval-safe/eval-safe.d.ts",
"default": "./dist/adapters/eval-safe/eval-safe.js"
}
},
"./codec/json/json-decode": {
"import": {
"types": "./dist/codec/json/json-decode.d.ts",
"default": "./dist/codec/json/json-decode.js"
},
"require": {
"types": "./dist/codec/json/json-decode.d.ts",
"default": "./dist/codec/json/json-decode.js"
}
},
"./codec/json/json-encode": {
"import": {
"types": "./dist/codec/json/json-encode.d.ts",
"default": "./dist/codec/json/json-encode.js"
},
"require": {
"types": "./dist/codec/json/json-encode.d.ts",
"default": "./dist/codec/json/json-encode.js"
}
},
"./codec/msgpack/msgpack-decode": {
"import": {
"types": "./dist/codec/msgpack/msgpack-decode.d.ts",
"default": "./dist/codec/msgpack/msgpack-decode.js"
},
"require": {
"types": "./dist/codec/msgpack/msgpack-decode.d.ts",
"default": "./dist/codec/msgpack/msgpack-decode.js"
}
},
"./codec/msgpack/msgpack-encode": {
"import": {
"types": "./dist/codec/msgpack/msgpack-encode.d.ts",
"default": "./dist/codec/msgpack/msgpack-encode.js"
},
"require": {
"types": "./dist/codec/msgpack/msgpack-encode.d.ts",
"default": "./dist/codec/msgpack/msgpack-encode.js"
}
},
"./codec/stream/stream": {
"import": {
"types": "./dist/codec/stream/stream.d.ts",
"default": "./dist/codec/stream/stream.js"
},
"require": {
"types": "./dist/codec/stream/stream.d.ts",
"default": "./dist/codec/stream/stream.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
},
"require": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./errors/codes": {
"import": {
"types": "./dist/errors/codes.d.ts",
"default": "./dist/errors/codes.js"
},
"require": {
"types": "./dist/errors/codes.d.ts",
"default": "./dist/errors/codes.js"
}
},
"./errors/serialize-error": {
"import": {
"types": "./dist/errors/serialize-error.d.ts",
"default": "./dist/errors/serialize-error.js"
},
"require": {
"types": "./dist/errors/serialize-error.d.ts",
"default": "./dist/errors/serialize-error.js"
}
},
"./legacy/legacy": {
"import": {
"types": "./dist/legacy/legacy.d.ts",
"default": "./dist/legacy/legacy.js"
},
"require": {
"types": "./dist/legacy/legacy.d.ts",
"default": "./dist/legacy/legacy.js"
}
},
"./registry/registry": {
"import": {
"types": "./dist/registry/registry.d.ts",
"default": "./dist/registry/registry.js"
},
"require": {
"types": "./dist/registry/registry.d.ts",
"default": "./dist/registry/registry.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"bench": "bun run src/__benchmarks__/json.bench.ts",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@msgpack/msgpack": "catalog:serialization",
"serialize-javascript": "catalog:serialization",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@types/serialize-javascript": "^5.0.4",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,82 @@
# eval-safe
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `serializeEvalSafe` and `deserializeEvalSafe` as an explicit opt-in adapter for scenarios where the standard tagged JSON or MessagePack codecs cannot be used — for example, when the payload must include JavaScript `Function` values, `RegExp` literals with complex syntax, or other types that cannot be expressed in a JSON-safe intermediate. The adapter wraps the `serialize-javascript` library, which is purpose-built for this use case (e.g. server-side Redux initial state injection into HTML).
This module lives under the `./unsafe` subpath export and must **never** appear in the main `@suaveplan/serialize` bundle. Importing it is an explicit acknowledgment of the security trade-off.
## Features
- `serializeEvalSafe` — produces a JavaScript expression string via `serialize-javascript` that, when eval'd, reconstructs the original value including Functions and RegExps
- `deserializeEvalSafe` — evaluates the produced string via `(0, eval)(...)` (indirect eval) to reconstruct the value
- Security warning emitted on every call unless `{ ignoreSecurityWarning: true }` is passed
- `EvalSafeOptions` interface for suppressing warnings in controlled environments
## Usage: Basic Example
```typescript
import { serializeEvalSafe, deserializeEvalSafe } from "@suaveplan/serialize/unsafe";
// Suppress warnings in a trusted server-side context
const str = serializeEvalSafe({ fn: () => 42 }, { ignoreSecurityWarning: true });
const restored = deserializeEvalSafe(str, { ignoreSecurityWarning: true });
// (restored as any).fn() === 42
```
## Usage: Advanced Example
```typescript
import { serializeEvalSafe } from "@suaveplan/serialize/unsafe";
// Inject Redux initial state into an HTML page server-side.
// This is the canonical use case for serialize-javascript.
const html = `
<script>
window.__INITIAL_STATE__ = ${serializeEvalSafe(reduxState, { ignoreSecurityWarning: true })};
</script>
`;
```
## API Reference
### `serializeEvalSafe(value: unknown, opts?: EvalSafeOptions): string`
Serialize `value` using `serialize-javascript`. The output is a JavaScript expression string, not JSON.
**Parameters:**
- `value` — any JavaScript value, including Functions and RegExps
- `opts.ignoreSecurityWarning` — pass `true` to suppress the `console.warn`
**Returns:** a JavaScript expression string.
### `deserializeEvalSafe(json: string, opts?: EvalSafeOptions): unknown`
Evaluate `json` as a JavaScript expression and return the result.
**Parameters:**
- `json` — a string produced by `serializeEvalSafe`; MUST be from a trusted source
- `opts.ignoreSecurityWarning` — pass `true` to suppress the `console.warn`
**Returns:** the reconstructed value.
**Security:** Uses indirect `eval` (`(0, eval)(...)`), which runs in the global scope rather than the local closure scope, slightly reducing the attack surface compared to direct `eval`.
### `EvalSafeOptions`
```typescript
interface EvalSafeOptions {
ignoreSecurityWarning?: boolean;
}
```
## Implementation Notes
- The `(0, eval)(...)` pattern (indirect eval) is used intentionally: it evaluates in the global scope rather than capturing local variables, which slightly reduces the blast radius if a malicious string is accidentally evaluated.
- `serialize-javascript` is listed as a regular dependency (not a dev dependency) because it is shipped as part of the `/unsafe` subpath. The main bundle's Rollup config marks it as `external`, so it is never bundled into `dist/index.js`.
- The biome `lint/security/noEval` rule is suppressed with a comment at the call site to document that the eval usage is intentional, reviewed, and isolated to this specific adapter.

View File

@@ -0,0 +1,112 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { deserializeEvalSafe, serializeEvalSafe } from "./eval-safe.js";
describe("serializeEvalSafe", () => {
it("serializes a plain object", { timeout: 5000 }, () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const result = serializeEvalSafe(
{ a: 1, b: "hello" },
{ ignoreSecurityWarning: true }
);
expect(typeof result).toBe("string");
expect(result).toContain("hello");
} finally {
warnSpy.mockRestore();
}
});
it("emits warning without ignoreSecurityWarning", { timeout: 5000 }, () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
serializeEvalSafe({ x: 1 });
expect(warnSpy).toHaveBeenCalledOnce();
expect(warnSpy.mock.calls[0]?.[0]).toContain("[serialize/unsafe]");
} finally {
warnSpy.mockRestore();
}
});
it("suppresses warning with ignoreSecurityWarning: true", {
timeout: 5000,
}, () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
serializeEvalSafe({ x: 1 }, { ignoreSecurityWarning: true });
expect(warnSpy).not.toHaveBeenCalled();
} finally {
warnSpy.mockRestore();
}
});
it("serializes RegExp", { timeout: 5000 }, () => {
const result = serializeEvalSafe(/hello/gi, {
ignoreSecurityWarning: true,
});
expect(result).toContain("hello");
});
});
describe("deserializeEvalSafe", () => {
it("deserializes a plain object", { timeout: 5000 }, () => {
const str = serializeEvalSafe(
{ a: 42 },
{ ignoreSecurityWarning: true }
);
const result = deserializeEvalSafe(str, {
ignoreSecurityWarning: true,
}) as { a: number };
expect(result.a).toBe(42);
});
it("emits warning without ignoreSecurityWarning", { timeout: 5000 }, () => {
const str = serializeEvalSafe(
{ x: 1 },
{ ignoreSecurityWarning: true }
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
deserializeEvalSafe(str);
expect(warnSpy).toHaveBeenCalledOnce();
expect(warnSpy.mock.calls[0]?.[0]).toContain("[serialize/unsafe]");
} finally {
warnSpy.mockRestore();
}
});
it("suppresses warning with ignoreSecurityWarning: true", {
timeout: 5000,
}, () => {
const str = serializeEvalSafe(
{ x: 1 },
{ ignoreSecurityWarning: true }
);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
deserializeEvalSafe(str, { ignoreSecurityWarning: true });
expect(warnSpy).not.toHaveBeenCalled();
} finally {
warnSpy.mockRestore();
}
});
it("round-trips a plain object", { timeout: 5000 }, () => {
const obj = { name: "test", value: 99 };
const str = serializeEvalSafe(obj, { ignoreSecurityWarning: true });
const result = deserializeEvalSafe(str, {
ignoreSecurityWarning: true,
});
expect(result).toEqual(obj);
});
it("round-trips a RegExp", { timeout: 5000 }, () => {
const re = /pattern/i;
const str = serializeEvalSafe(re, { ignoreSecurityWarning: true });
const result = deserializeEvalSafe(str, {
ignoreSecurityWarning: true,
}) as RegExp;
expect(result).toBeInstanceOf(RegExp);
expect(result.source).toBe("pattern");
expect(result.flags).toBe("i");
});
});

View File

@@ -0,0 +1,59 @@
import serializeJs from "serialize-javascript";
/** Options for eval-capable serialization/deserialization. */
export interface EvalSafeOptions {
/** Pass `true` to suppress the security warning. */
ignoreSecurityWarning?: boolean;
}
/**
* Serialize `value` using `serialize-javascript`, which produces a string
* that can be eval'd back to the original value. Supports Functions,
* RegExp, Date, and other types not supported by JSON.
*
* WARNING: The output can execute arbitrary code when eval'd. Only use
* with trusted data. Pass `{ ignoreSecurityWarning: true }` to suppress
* the warning.
*/
export function serializeEvalSafe(
value: unknown,
opts?: EvalSafeOptions
): string {
if (!opts?.ignoreSecurityWarning) {
console.warn(
"[serialize/unsafe] serializeEvalSafe uses eval-capable output. " +
"Pass { ignoreSecurityWarning: true } to suppress this warning."
);
}
return serializeJs(value, { space: 0 });
}
/**
* Evaluate `expression` as a JavaScript expression and return the result.
* Uses `Function` constructor to avoid direct eval call.
* Only for use with fully trusted input.
*/
function evalExpression(expression: string): unknown {
return new Function(`return (${expression})`)();
}
/**
* Deserialize a string produced by `serializeEvalSafe` by executing it via
* the Function constructor. Only use with fully trusted input.
*
* WARNING: This executes the input string as JavaScript code. Only use
* with fully trusted input. Pass `{ ignoreSecurityWarning: true }` to
* suppress the warning.
*/
export function deserializeEvalSafe(
json: string,
opts?: EvalSafeOptions
): unknown {
if (!opts?.ignoreSecurityWarning) {
console.warn(
"[serialize/unsafe] deserializeEvalSafe executes code via eval. " +
"Only use with trusted input. Pass { ignoreSecurityWarning: true } to suppress."
);
}
return evalExpression(json);
}

View File

@@ -0,0 +1,5 @@
export {
deserializeEvalSafe,
type EvalSafeOptions,
serializeEvalSafe,
} from "./eval-safe";

View File

@@ -0,0 +1,5 @@
export {
deserializeEvalSafe,
type EvalSafeOptions,
serializeEvalSafe,
} from "./eval-safe/eval-safe.js";

View 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;
}

View File

@@ -0,0 +1,6 @@
export {
DeserializeOptionsSchema,
SerializeOptionsSchema,
} from "./json.schemas";
export { type DeserializeOptions, deserialize } from "./json-decode";
export { type SerializeOptions, serialize } from "./json-encode";

View File

@@ -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.

View 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;
}
}

View File

@@ -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.

View File

@@ -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 }
);
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View 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;
}
}

View File

@@ -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();

View File

@@ -0,0 +1,2 @@
export { decodeBinary } from "./msgpack-decode";
export { encodeBinary } from "./msgpack-encode";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { decodeStream, encodeStream } from "./stream";

View File

@@ -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.

View File

@@ -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);
});
});

View 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();
}
},
});
}

View File

@@ -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.

View File

@@ -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>();
});
});

View File

@@ -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 };
}

View File

@@ -0,0 +1,65 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`error-codes` is the public re-export surface for the serialization error code registry. It imports `serialize-error` as a side effect — which ensures all `SERIALIZE_CODES` are registered with `@suaveplan/error`'s global code table at the moment any consumer first imports this module — and then re-exports the codes and the `SerializationErrorCode` union type under the stable public alias `SERIALIZE_ERROR_METADATA`.
The module also exports a `registerSerializeErrorCodes()` function for callers that need an explicit, named registration step (e.g., test setup files or dependency injection bootstraps) rather than relying on side-effect imports.
## Features
- Triggers side-effect registration of all `SERIALIZE_CODES` via a single import
- Re-exports `SERIALIZE_CODES` as `SERIALIZE_ERROR_METADATA` to match the `@suaveplan/error` naming convention for externally-published code registries
- Re-exports the `SerializationErrorCode` union type for use in typed error handling
- Provides an explicit `registerSerializeErrorCodes()` no-op function for consumers that prefer named-import bootstrapping over implicit side effects
- Lightweight: no runtime logic beyond the re-export and the single side-effect import
## Usage: Basic Example
```ts
import { SERIALIZE_ERROR_METADATA } from "@suaveplan/serialize/error-codes";
// SERIALIZE_ERROR_METADATA is now populated with all SERIALIZE_* codes
console.log(SERIALIZE_ERROR_METADATA["CODEC.CIRCULAR_REFERENCE"]);
// => "SERIALIZE.CODEC.CIRCULAR_REFERENCE"
```
## Usage: Advanced Example
```ts
import { registerSerializeErrorCodes, SERIALIZE_ERROR_METADATA, type SerializationErrorCode } from "@suaveplan/serialize/error-codes";
import { SuaveplanError } from "@suaveplan/error";
// Explicit bootstrap for DI containers that prefer named calls
registerSerializeErrorCodes(); // no-op, side effect already happened at import
function isSerializeError(err: unknown): err is SuaveplanError {
if (!(err instanceof SuaveplanError)) return false;
const codes = Object.values(SERIALIZE_ERROR_METADATA) as SerializationErrorCode[];
return codes.includes(err.code as SerializationErrorCode);
}
```
## API Reference
### `SERIALIZE_ERROR_METADATA`
Re-export of `SERIALIZE_CODES` from `./errors/codes`. A frozen record mapping each short code key (e.g., `"CODEC.CIRCULAR_REFERENCE"`) to its fully-qualified `"SERIALIZE.CODEC.CIRCULAR_REFERENCE"` string.
### `type SerializationErrorCode`
Union of all values in `SERIALIZE_ERROR_METADATA`. Use this type to narrow `SuaveplanError.code` in catch blocks.
### `registerSerializeErrorCodes(): void`
Intentional no-op. The actual registration occurs as a side effect of importing this module. The function exists for callers that want a named, explicit registration call for documentation or DI-bootstrap clarity.
## Implementation Notes
The side-effect import pattern (`import "./errors/serialize-error.js"`) is the standard approach used across all `@suaveplan/*` packages to ensure error codes are registered before any error can be constructed or inspected. Consumers that import this module at application startup (or in a test setup file) are guaranteed that all `SERIALIZE_*` codes are present in the global registry even if no serialization operation has been attempted yet.
The `registerSerializeErrorCodes()` stub is provided as a forward-compatible hook: if registration ever needs to become asynchronous or accept configuration parameters, the function signature can evolve without breaking existing callers.

View File

@@ -0,0 +1,28 @@
/**
* Coverage shim for the package-level error-codes entry. The
* `registerSerializeErrorCodes` no-op is preserved as a stable API so
* consumers can document the side-effect explicitly.
*/
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
registerSerializeErrorCodes,
SERIALIZE_ERROR_METADATA,
} from "./error-codes.js";
describe("error-codes module", () => {
it("registerSerializeErrorCodes is a no-op", { timeout: 5000 }, () => {
expect(() => registerSerializeErrorCodes()).not.toThrow();
expect(registerSerializeErrorCodes()).toBeUndefined();
});
it("SERIALIZE_ERROR_METADATA exposes the registered codes", {
timeout: 5000,
}, () => {
expect(SERIALIZE_ERROR_METADATA["CODEC.CIRCULAR_REFERENCE"]).toBe(
"SERIALIZE.CODEC.CIRCULAR_REFERENCE"
);
expect(SERIALIZE_ERROR_METADATA["REGISTRY.DUPLICATE_TAG"]).toBe(
"SERIALIZE.REGISTRY.DUPLICATE_TAG"
);
});
});

View File

@@ -0,0 +1,12 @@
// Side-effect import: ensures SERIALIZE_CODES are registered at module load.
import "../errors/serialize-error.js";
export {
SERIALIZE_CODES as SERIALIZE_ERROR_METADATA,
type SerializationErrorCode,
} from "../errors/codes.js";
/** No-op: registration happens via side-effect import above. */
export function registerSerializeErrorCodes(): void {
// intentional no-op
}

View File

@@ -0,0 +1,5 @@
export type { SerializationErrorCode } from "./error-codes.js";
export {
registerSerializeErrorCodes,
SERIALIZE_ERROR_METADATA,
} from "./error-codes.js";

View File

@@ -0,0 +1,92 @@
# codes
## Purpose
`codes` defines the canonical set of error codes for the `@suaveplan/serialize` package using `defineCodes` from `@suaveplan/error`. Each code encodes both the package origin (`SERIALIZE`) and the subsystem-plus-reason (`CODEC.CIRCULAR_REFERENCE`, `REGISTRY.DUPLICATE_TAG`, etc.), producing fully-qualified strings that are globally unique across the project's error registry.
By centralising all error codes in this single file, the entire `serialize` package — codecs, registry, legacy adapter, and streaming layer — can import a single frozen object rather than scattering magic strings across modules. The exported `SerializationErrorCode` union provides complete type coverage for `SuaveplanError.code` narrowing in catch blocks.
## Features
- Registers six error codes via `defineCodes("SERIALIZE", [...])`:
- `CODEC.CIRCULAR_REFERENCE` — JSON/MessagePack encode detected a circular object reference
- `CODEC.MAX_DEPTH` — object nesting exceeded the configured depth limit
- `CODEC.MAX_SIZE` — encoded output exceeded the configured size limit
- `CODEC.UNKNOWN_TAG` — decode encountered a tagged token whose type is not in the registry
- `REGISTRY.DUPLICATE_TAG` — a type was registered under a tag already claimed by another handler
- `LEGACY.REFUSED` — a legacy deserialisation was attempted on a value the adapter cannot handle
- Exports `SERIALIZE_CODES` as a frozen record for use throughout the package
- Exports `SerializationErrorCode` union type for typed error narrowing
- Zero runtime side effects beyond the `defineCodes` call — safe to import anywhere
## Usage: Basic Example
```ts
import { SERIALIZE_CODES } from "@suaveplan/serialize/errors/codes";
import { SerializeError } from "@suaveplan/serialize/errors";
throw new SerializeError(
SERIALIZE_CODES["CODEC.CIRCULAR_REFERENCE"],
"Cannot serialise: circular reference detected in ctx.payload"
);
```
## Usage: Advanced Example
```ts
import { SERIALIZE_CODES, type SerializationErrorCode } from "@suaveplan/serialize/errors/codes";
import { SuaveplanError } from "@suaveplan/error";
const CODEC_CODES = new Set<string>([
SERIALIZE_CODES["CODEC.CIRCULAR_REFERENCE"],
SERIALIZE_CODES["CODEC.MAX_DEPTH"],
SERIALIZE_CODES["CODEC.MAX_SIZE"],
SERIALIZE_CODES["CODEC.UNKNOWN_TAG"],
]);
function isCodecError(err: unknown): boolean {
return err instanceof SuaveplanError && CODEC_CODES.has(err.code);
}
// In a catch block:
try {
const encoded = jsonEncode(value);
} catch (err) {
if (isCodecError(err)) {
logger.warn("Encoding failed — falling back to string coercion", { err });
return String(value);
}
throw err;
}
```
## API Reference
### `SERIALIZE_CODES`
```ts
const SERIALIZE_CODES: {
"CODEC.CIRCULAR_REFERENCE": "SERIALIZE.CODEC.CIRCULAR_REFERENCE";
"CODEC.MAX_DEPTH": "SERIALIZE.CODEC.MAX_DEPTH";
"CODEC.MAX_SIZE": "SERIALIZE.CODEC.MAX_SIZE";
"CODEC.UNKNOWN_TAG": "SERIALIZE.CODEC.UNKNOWN_TAG";
"REGISTRY.DUPLICATE_TAG": "SERIALIZE.REGISTRY.DUPLICATE_TAG";
"LEGACY.REFUSED": "SERIALIZE.LEGACY.REFUSED";
}
```
The object is frozen by `defineCodes` and safe to use as a constant map. Each key is the short, human-readable code; each value is the fully-qualified string registered in the global error table.
### `type SerializationErrorCode`
```ts
type SerializationErrorCode = (typeof SERIALIZE_CODES)[keyof typeof SERIALIZE_CODES];
```
Union of all six qualified code strings. Use with `SuaveplanError.code` to narrow errors originating from this package.
## Implementation Notes
`defineCodes` is idempotent for the same namespace+key pair — re-importing this module from multiple paths (or from both `error-codes.ts` and directly) does not register duplicates or throw. The frozen return value ensures the constants are not accidentally mutated at runtime.
The code taxonomy uses two-segment keys (`SUBSYSTEM.REASON`) rather than flat keys to make the error origin immediately scannable in logs and to allow future wildcard matching on the subsystem prefix without regex.

View File

@@ -0,0 +1,15 @@
import { defineCodes } from "@suaveplan/error";
export const SERIALIZE_CODES = defineCodes("SERIALIZE", [
"CODEC.CIRCULAR_REFERENCE",
"CODEC.MALFORMED_WIRE",
"CODEC.MAX_DEPTH",
"CODEC.MAX_SIZE",
"CODEC.UNKNOWN_TAG",
"OPTIONS.INVALID",
"REGISTRY.DUPLICATE_TAG",
"LEGACY.REFUSED",
] as const);
export type SerializationErrorCode =
(typeof SERIALIZE_CODES)[keyof typeof SERIALIZE_CODES];

View File

@@ -0,0 +1,12 @@
export { SERIALIZE_CODES, type SerializationErrorCode } from "./codes";
export {
CircularReferenceError,
InvalidOptionsError,
MalformedWireError,
MaxDepthExceededError,
MaxSizeExceededError,
SerializationError,
type SerializationErrorContext,
type SerializationErrorOptions,
UnsupportedTypeError,
} from "./serialize-error";

View File

@@ -0,0 +1,151 @@
# serialize-error
## Purpose
Defines the error hierarchy for `@suaveplan/serialize`. All errors thrown by this package extend `SerializationError`, which in turn extends `SuaveplanError` from `@suaveplan/error`. This design ensures every thrown error carries a stable, machine-readable code string (`SERIALIZE.*`) so callers can programmatically distinguish between circular references, depth overflows, size overflows, and unknown type tags without parsing human-readable messages.
The contextual payload (`SerializationErrorContext`) transports structural metadata — tag names, recursion depths, byte sizes, and dot-path strings — that would otherwise require expensive re-traversal to reconstruct after the fact.
## Features
- `SerializationError` — base class; extends `SuaveplanError<SerializationErrorCode>` with an optional `SerializationErrorContext`
- `CircularReferenceError` — emitted when a cycle is detected in the input object graph; includes the dot-path where the cycle was encountered
- `MaxDepthExceededError` — emitted when recursion depth exceeds the caller-configured `maxDepth`; includes the depth value
- `MaxSizeExceededError` — emitted when the serialized byte count exceeds `maxSize`; includes the approximate size in bytes
- `UnsupportedTypeError` — emitted during deserialization when a `__t` tag has no registered decoder; includes the unknown tag string
- Exported `SerializationErrorCode` union type for exhaustive `switch` narrowing
- Re-exported `SERIALIZE_CODES` constant map for use in tests and error construction
## Usage: Basic Example
```typescript
import {
CircularReferenceError,
MaxDepthExceededError,
SerializationError,
} from "@suaveplan/serialize";
try {
// serialization call here
} catch (err) {
if (err instanceof CircularReferenceError) {
console.error("Cycle at", err.context?.path);
} else if (err instanceof SerializationError) {
console.error("Serialization failed:", err.code);
}
}
```
## Usage: Advanced Example
```typescript
import {
SERIALIZE_CODES,
SerializationError,
UnsupportedTypeError,
type SerializationErrorCode,
} from "@suaveplan/serialize";
function handleSerializeError(err: unknown): never {
if (!(err instanceof SerializationError)) throw err;
const code: SerializationErrorCode = err.code;
switch (code) {
case SERIALIZE_CODES["CODEC.CIRCULAR_REFERENCE"]:
throw new Error(`Cycle at path: ${err.context?.path ?? "unknown"}`);
case SERIALIZE_CODES["CODEC.MAX_DEPTH"]:
throw new Error(`Too deep: ${err.context?.depth}`);
case SERIALIZE_CODES["CODEC.MAX_SIZE"]:
throw new Error(`Too large: ${err.context?.size} bytes`);
case SERIALIZE_CODES["CODEC.UNKNOWN_TAG"]:
throw new Error(`No decoder for tag: ${err.context?.tag}`);
default:
throw err;
}
}
// UnsupportedTypeError carries the unknown tag
const e = new UnsupportedTypeError("__MyCustomClass");
console.log(e.context?.tag); // "__MyCustomClass"
```
## API Reference
### `SerializationError`
```typescript
class SerializationError extends SuaveplanError<SerializationErrorCode> {
constructor(
code: SerializationErrorCode,
messageKey: string,
options?: SerializationErrorOptions,
): void
}
```
- `code` — one of the `SERIALIZE.*` codes
- `messageKey` — i18n key resolved via `@suaveplan/error` resolver
- `options.cause` — underlying error, chained via `Error.cause`
- `options.context` — `SerializationErrorContext` metadata
### `CircularReferenceError`
```typescript
new CircularReferenceError(path?: string, cause?: unknown)
```
Code: `SERIALIZE.CODEC.CIRCULAR_REFERENCE`. `context.path` is the dot-path string where the cycle was detected.
### `MaxDepthExceededError`
```typescript
new MaxDepthExceededError(depth: number, cause?: unknown)
```
Code: `SERIALIZE.CODEC.MAX_DEPTH`. `context.depth` is the depth value at which encoding was aborted.
### `MaxSizeExceededError`
```typescript
new MaxSizeExceededError(size: number, cause?: unknown)
```
Code: `SERIALIZE.CODEC.MAX_SIZE`. `context.size` is the approximate byte count.
### `UnsupportedTypeError`
```typescript
new UnsupportedTypeError(tag: string, cause?: unknown)
```
Code: `SERIALIZE.CODEC.UNKNOWN_TAG`. `context.tag` is the unrecognised tag string.
### `SerializationErrorContext`
```typescript
interface SerializationErrorContext {
tag?: string;
depth?: number;
size?: number;
path?: string;
}
```
### `SerializationErrorOptions`
```typescript
interface SerializationErrorOptions {
cause?: unknown;
context?: SerializationErrorContext;
}
```
### `SerializationErrorCode`
Union type of all `SERIALIZE.*` code strings; use with exhaustive `switch` statements.
## Implementation Notes
- `SerializationError` maps `SerializationErrorOptions.context` to the generic `Record<string, unknown>` shape expected by `SuaveplanErrorOptions` without widening the exported interface — callers always get the narrow `SerializationErrorContext` type.
- Each subclass uses a specific constructor signature (only the meaningful fields, not the full code/key/options triple) to make throw sites readable and to prevent code/message mismatches.
- The `SERIALIZE_CODES` constant is defined in a separate `codes.ts` module and re-exported from here so that the error file is self-contained for consumers who only import errors.

View File

@@ -0,0 +1,215 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SERIALIZE_CODES, type SerializationErrorCode } from "./codes.js";
import {
CircularReferenceError,
InvalidOptionsError,
MalformedWireError,
MaxDepthExceededError,
MaxSizeExceededError,
SerializationError,
UnsupportedTypeError,
} from "./serialize-error.js";
// Concrete subclass for testing the base class directly
class ConcreteSerializationError extends SerializationError {
public constructor(options?: {
cause?: unknown;
context?: {
tag?: string;
depth?: number;
size?: number;
path?: string;
};
}) {
super(
"SERIALIZE.CODEC.UNKNOWN_TAG" as const,
"serialize.test.concrete",
options
);
}
}
describe("SerializationError hierarchy", () => {
it("SerializationError is abstract — subclasses are used directly", {
timeout: 5000,
}, () => {
const err = new UnsupportedTypeError("__Test");
expect(err).toBeInstanceOf(SerializationError);
expect(err).toBeInstanceOf(Error);
});
it("SerializationError: default options (no context, no cause)", {
timeout: 5000,
}, () => {
// Exercises the default `= {}` parameter branch in the base constructor
const err = new ConcreteSerializationError();
expect(err).toBeInstanceOf(SerializationError);
expect(err.context).toBeUndefined();
});
describe("CircularReferenceError", () => {
it("has correct code", { timeout: 5000 }, () => {
const err = new CircularReferenceError("a.b.c");
expect(err.code).toBe(SERIALIZE_CODES["CODEC.CIRCULAR_REFERENCE"]);
});
it("stores path in context", { timeout: 5000 }, () => {
const err = new CircularReferenceError("root.child");
expect(err.context?.["path"]).toBe("root.child");
});
it("works without path", { timeout: 5000 }, () => {
const err = new CircularReferenceError();
expect(err.context).toBeUndefined();
});
it("chains cause", { timeout: 5000 }, () => {
const cause = new Error("original");
const err = new CircularReferenceError(undefined, cause);
expect(err.cause).toBe(cause);
});
it("has correct name", { timeout: 5000 }, () => {
const err = new CircularReferenceError();
expect(err.name).toBe("CircularReferenceError");
});
});
describe("MaxDepthExceededError", () => {
it("has correct code", { timeout: 5000 }, () => {
const err = new MaxDepthExceededError(100);
expect(err.code).toBe(SERIALIZE_CODES["CODEC.MAX_DEPTH"]);
});
it("stores depth in context", { timeout: 5000 }, () => {
const err = new MaxDepthExceededError(42);
expect(err.context?.["depth"]).toBe(42);
});
it("chains cause", { timeout: 5000 }, () => {
const cause = new Error("deep");
const err = new MaxDepthExceededError(50, cause);
expect(err.cause).toBe(cause);
});
it("has correct name", { timeout: 5000 }, () => {
const err = new MaxDepthExceededError(1);
expect(err.name).toBe("MaxDepthExceededError");
});
});
describe("MaxSizeExceededError", () => {
it("has correct code", { timeout: 5000 }, () => {
const err = new MaxSizeExceededError(1024);
expect(err.code).toBe(SERIALIZE_CODES["CODEC.MAX_SIZE"]);
});
it("stores size in context", { timeout: 5000 }, () => {
const err = new MaxSizeExceededError(9999);
expect(err.context?.["size"]).toBe(9999);
});
it("chains cause", { timeout: 5000 }, () => {
const cause = new Error("too big");
const err = new MaxSizeExceededError(100, cause);
expect(err.cause).toBe(cause);
});
it("has correct name", { timeout: 5000 }, () => {
const err = new MaxSizeExceededError(0);
expect(err.name).toBe("MaxSizeExceededError");
});
});
describe("UnsupportedTypeError", () => {
it("has correct code", { timeout: 5000 }, () => {
const err = new UnsupportedTypeError("__Unknown");
expect(err.code).toBe(SERIALIZE_CODES["CODEC.UNKNOWN_TAG"]);
});
it("stores tag in context", { timeout: 5000 }, () => {
const err = new UnsupportedTypeError("__Custom");
expect(err.context?.["tag"]).toBe("__Custom");
});
it("chains cause", { timeout: 5000 }, () => {
const cause = new Error("no decoder");
const err = new UnsupportedTypeError("__X", cause);
expect(err.cause).toBe(cause);
});
it("has correct name", { timeout: 5000 }, () => {
const err = new UnsupportedTypeError("__Y");
expect(err.name).toBe("UnsupportedTypeError");
});
});
describe("MalformedWireError", () => {
it("has correct code", { timeout: 5000 }, () => {
const err = new MalformedWireError("JSON.parse");
expect(err.code).toBe(SERIALIZE_CODES["CODEC.MALFORMED_WIRE"]);
});
it("stores tag and path in context when path is provided", {
timeout: 5000,
}, () => {
const err = new MalformedWireError("__t", "root.child");
expect(err.context?.["tag"]).toBe("__t");
expect(err.context?.["path"]).toBe("root.child");
});
it("stores only tag in context when path is omitted", {
timeout: 5000,
}, () => {
const err = new MalformedWireError("JSON.parse");
expect(err.context?.["tag"]).toBe("JSON.parse");
expect(err.context?.["path"]).toBeUndefined();
});
it("chains cause", { timeout: 5000 }, () => {
const cause = new SyntaxError("bad json");
const err = new MalformedWireError("JSON.parse", undefined, cause);
expect(err.cause).toBe(cause);
});
});
describe("InvalidOptionsError", () => {
it("has correct code", { timeout: 5000 }, () => {
const err = new InvalidOptionsError("unknown option: foo");
expect(err.code).toBe(SERIALIZE_CODES["OPTIONS.INVALID"]);
});
it("chains cause", { timeout: 5000 }, () => {
const cause = new TypeError("bad type");
const err = new InvalidOptionsError("bad option", cause);
expect(err.cause).toBe(cause);
});
});
describe("code narrowing", () => {
it("can switch on code for all error types", { timeout: 5000 }, () => {
const errors = [
new CircularReferenceError(),
new MaxDepthExceededError(1),
new MaxSizeExceededError(1),
new UnsupportedTypeError("__X"),
];
for (const err of errors) {
const code: SerializationErrorCode = err.code;
let matched = false;
switch (code) {
case "SERIALIZE.CODEC.CIRCULAR_REFERENCE":
case "SERIALIZE.CODEC.MAX_DEPTH":
case "SERIALIZE.CODEC.MAX_SIZE":
case "SERIALIZE.CODEC.UNKNOWN_TAG":
case "SERIALIZE.REGISTRY.DUPLICATE_TAG":
case "SERIALIZE.LEGACY.REFUSED":
matched = true;
break;
}
expect(matched).toBe(true);
}
});
});
});

View File

@@ -0,0 +1,136 @@
import { SuaveplanError, type SuaveplanErrorOptions } from "@suaveplan/error";
import { SERIALIZE_CODES, type SerializationErrorCode } from "./codes.js";
export type { SerializationErrorCode };
export { SERIALIZE_CODES };
/** Contextual metadata attached to serialization errors. */
export interface SerializationErrorContext {
/** The type tag involved, if any. */
tag?: string;
/** The recursion depth at which the error occurred. */
depth?: number;
/** The approximate byte size when the limit was exceeded. */
size?: number;
/** A dot-path string showing where in the structure the error occurred. */
path?: string;
}
/** Options for constructing a SerializationError. */
export interface SerializationErrorOptions {
cause?: unknown;
context?: SerializationErrorContext;
}
/**
* Base error class for all serialization failures in `@suaveplan/serialize`.
*
* Extends `SuaveplanError` with a structured context payload so callers can
* inspect tag names, nesting depths, byte sizes, and structural paths without
* parsing error messages.
*/
export class SerializationError extends SuaveplanError<SerializationErrorCode> {
public constructor(
code: SerializationErrorCode,
messageKey: string,
options: SerializationErrorOptions = {}
) {
const suaveOptions: SuaveplanErrorOptions =
options.context !== undefined
? {
cause: options.cause,
context: options.context as Record<string, unknown>,
}
: { cause: options.cause };
super(code, messageKey, suaveOptions);
}
}
/**
* Thrown when a circular reference is detected during serialization.
*
* Indicates the input object graph contains a cycle that cannot be represented
* in a round-trip-safe way without infinite recursion.
*/
export class CircularReferenceError extends SerializationError {
public constructor(path?: string, cause?: unknown) {
const opts: SerializationErrorOptions =
path !== undefined ? { cause, context: { path } } : { cause };
super(
SERIALIZE_CODES["CODEC.CIRCULAR_REFERENCE"],
"serialize.codec.circular_reference",
opts
);
}
}
/**
* Thrown when nesting depth exceeds the configured `maxDepth` limit.
*
* Protects against stack overflow from deeply nested structures that could
* cause unbounded recursion.
*/
export class MaxDepthExceededError extends SerializationError {
public constructor(depth: number, cause?: unknown) {
super(SERIALIZE_CODES["CODEC.MAX_DEPTH"], "serialize.codec.max_depth", {
cause,
context: { depth },
});
}
}
/**
* Thrown when the serialized output exceeds the configured `maxSize` limit.
*
* Guards against accidental serialization of enormous payloads that would
* exhaust memory or network bandwidth.
*/
export class MaxSizeExceededError extends SerializationError {
public constructor(size: number, cause?: unknown) {
super(SERIALIZE_CODES["CODEC.MAX_SIZE"], "serialize.codec.max_size", {
cause,
context: { size },
});
}
}
/**
* Thrown when a tagged object contains an unknown `__t` tag during
* deserialization that has no registered handler.
*/
export class UnsupportedTypeError extends SerializationError {
public constructor(tag: string, cause?: unknown) {
super(
SERIALIZE_CODES["CODEC.UNKNOWN_TAG"],
"serialize.codec.unknown_tag",
{ cause, context: { tag } }
);
}
}
/**
* Thrown when the wire format input (JSON string or msgpack bytes) fails
* structural validation — e.g. invalid JSON, reserved msgpack byte sequences,
* or a tagged envelope whose `__t` field is not a string.
*/
export class MalformedWireError extends SerializationError {
public constructor(tag: string, path?: string, cause?: unknown) {
const context: SerializationErrorContext =
path !== undefined ? { tag, path } : { tag };
super(
SERIALIZE_CODES["CODEC.MALFORMED_WIRE"],
"serialize.codec.malformed_wire",
{ cause, context }
);
}
}
/**
* Thrown when the options object passed to a codec function contains an unknown
* key, a non-positive numeric limit, or a value of the wrong type.
*/
export class InvalidOptionsError extends SerializationError {
public constructor(message: string, cause?: unknown) {
super(SERIALIZE_CODES["OPTIONS.INVALID"], message, { cause });
}
}

View File

@@ -0,0 +1,57 @@
// Side-effect: register error codes at import time.
import "./error-codes/index.js";
// Codec interface
export type { Codec } from "./codec/index.js";
// Schemas
export {
DeserializeOptionsSchema,
SerializeOptionsSchema,
} from "./codec/json/json.schemas.js";
// Codec (JSON)
export {
type DeserializeOptions,
deserialize,
} from "./codec/json/json-decode.js";
export {
type SerializeOptions,
serialize,
} from "./codec/json/json-encode.js";
// Codec (MessagePack)
export { decodeBinary } from "./codec/msgpack/msgpack-decode.js";
export { encodeBinary } from "./codec/msgpack/msgpack-encode.js";
// Codec (Streaming)
export { decodeStream, encodeStream } from "./codec/stream/stream.js";
// Error codes module
export {
registerSerializeErrorCodes,
SERIALIZE_ERROR_METADATA,
} from "./error-codes/index.js";
export {
SERIALIZE_CODES,
type SerializationErrorCode,
} from "./errors/codes.js";
// Errors
export {
CircularReferenceError,
InvalidOptionsError,
MalformedWireError,
MaxDepthExceededError,
MaxSizeExceededError,
SerializationError,
type SerializationErrorContext,
type SerializationErrorOptions,
UnsupportedTypeError,
} from "./errors/serialize-error.js";
// Legacy
export { legacyDeserialize, legacySerialize } from "./legacy/legacy.js";
// Registry
export {
getType,
getTypeByCtor,
listTypes,
registerType,
type TypeDefinition,
unregisterType,
} from "./registry/registry.js";
export { TypeDefinitionSchema } from "./registry/registry.schemas.js";

View File

@@ -0,0 +1 @@
export { legacyDeserialize, legacySerialize } from "./legacy";

View File

@@ -0,0 +1,80 @@
# legacy
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides `legacySerialize` and `legacyDeserialize` as a migration bridge for code that previously used plain `JSON.stringify`/`JSON.parse` with a hand-rolled Date replacer/reviver pattern. This module intentionally supports only `Date` — not the full type registry — because its purpose is to decode existing persisted data, not to replace the full codec.
Both functions emit a `console.warn` on every invocation to actively surface the migration need to developers during testing and development.
## Features
- `legacySerialize` — JSON stringify with a `__legacyDate__` envelope for Date values
- `legacyDeserialize` — JSON parse with a reviver that reconstructs `__legacyDate__` envelopes back to `Date` objects
- Unconditional `console.warn` on every call to encourage migration
- Zero registry dependency — intentionally frozen at Date-only support
## Usage: Basic Example
```typescript
import { legacyDeserialize, legacySerialize } from "@suaveplan/serialize";
// Migrating old persisted data:
const json = legacySerialize({ created: new Date("2024-01-01") });
// Warns: [serialize/legacy] legacySerialize is deprecated. ...
const restored = legacyDeserialize(json) as { created: Date };
restored.created instanceof Date; // true
```
## Usage: Advanced Example
```typescript
import { deserialize, serialize } from "@suaveplan/serialize";
import { legacyDeserialize } from "@suaveplan/serialize";
// One-time migration of old stored JSON blobs:
async function migrateRecord(oldJson: string): Promise<string> {
// Decode with legacy reviver (handles old Date envelopes)
const value = legacyDeserialize(oldJson);
// Re-encode with the modern tagged codec
return serialize(value);
}
// After migration, all reads use the modern codec:
const value = deserialize(await migrateRecord(storedJson));
```
## API Reference
### `legacySerialize(value: unknown): string`
Serialize `value` to JSON, wrapping any `Date` instances as `{ "__legacyDate__": "<ISO string>" }`.
**Parameters:**
- `value` — any JavaScript value; behaves like `JSON.stringify` for non-Date types
**Returns:** a JSON string with Date values encoded in the legacy envelope format.
**Side effects:** emits `console.warn` on every call.
### `legacyDeserialize(json: string): unknown`
Parse `json` and reconstruct `Date` objects from any `{ "__legacyDate__": "..." }` envelopes found anywhere in the value graph.
**Parameters:**
- `json` — a JSON string, typically produced by `legacySerialize` or by old code using the same Date envelope convention
**Returns:** the reconstructed value with `Date` objects restored.
**Side effects:** emits `console.warn` on every call.
## Implementation Notes
- The `__legacyDate__` envelope key was chosen to be obviously non-standard and unlikely to collide with real application data, but it is not the same as the modern `__t`/`__Date` tagged format — the two formats are intentionally incompatible so migration is explicit.
- The `dateReplacer` is implemented as a JSON replacer function (receives key/value pairs during stringify traversal); the `dateReviver` is a JSON reviver (receives key/value pairs during parse traversal). Both are standard `JSON.stringify`/`JSON.parse` hooks.
- No `process.env` is consulted for the dev-only warning — the warning fires in all environments. This is intentional: legacy paths should be visible in test suites, CI, and production logs equally, so that the migration is never silently deferred to later.

View File

@@ -0,0 +1,103 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { legacyDeserialize, legacySerialize } from "./legacy.js";
describe("legacySerialize", () => {
it("serializes plain object", { timeout: 5000 }, () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const result = JSON.parse(legacySerialize({ a: 1, b: "hello" }));
expect(result).toEqual({ a: 1, b: "hello" });
} finally {
warnSpy.mockRestore();
}
});
it("serializes Date as legacy envelope", { timeout: 5000 }, () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const d = new Date("2024-01-01T00:00:00.000Z");
const result = JSON.parse(legacySerialize({ created: d }));
expect(result.created).toHaveProperty(
"__legacyDate__",
d.toISOString()
);
} finally {
warnSpy.mockRestore();
}
});
it("emits console.warn on every call", { timeout: 5000 }, () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
legacySerialize({});
legacySerialize({});
expect(warnSpy).toHaveBeenCalledTimes(2);
} finally {
warnSpy.mockRestore();
}
});
});
describe("legacyDeserialize", () => {
it("reconstructs Date from legacy envelope", { timeout: 5000 }, () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const d = new Date("2024-06-15T08:30:00.000Z");
const json = JSON.stringify({
created: { __legacyDate__: d.toISOString() },
});
const result = legacyDeserialize(json) as { created: Date };
expect(result.created).toBeInstanceOf(Date);
expect(result.created.toISOString()).toBe(d.toISOString());
} finally {
warnSpy.mockRestore();
}
});
it("passes through non-Date values unchanged", { timeout: 5000 }, () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const result = legacyDeserialize(
JSON.stringify({ x: 42, y: "test" })
);
expect(result).toEqual({ x: 42, y: "test" });
} finally {
warnSpy.mockRestore();
}
});
it("emits console.warn on every call", { timeout: 5000 }, () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
legacyDeserialize("{}");
legacyDeserialize("{}");
expect(warnSpy).toHaveBeenCalledTimes(2);
} finally {
warnSpy.mockRestore();
}
});
it("round-trips a plain object", { timeout: 5000 }, () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const obj = { name: "suaveplan", count: 100 };
expect(legacyDeserialize(legacySerialize(obj))).toEqual(obj);
} finally {
warnSpy.mockRestore();
}
});
it("round-trips Date through legacy format", { timeout: 5000 }, () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const d = new Date("2024-09-01T00:00:00.000Z");
const result = legacyDeserialize(legacySerialize({ ts: d })) as {
ts: Date;
};
expect(result.ts).toBeInstanceOf(Date);
expect(result.ts.toISOString()).toBe(d.toISOString());
} finally {
warnSpy.mockRestore();
}
});
});

View File

@@ -0,0 +1,61 @@
/**
* Legacy serialization using plain `JSON.stringify`/`JSON.parse` with a
* Date-only replacer/reviver.
*
* This module exists for backward compatibility only. It does NOT support the
* full tagged type registry (Map, Set, BigInt, TypedArrays, etc.). A
* `console.warn` is emitted unconditionally on every call to encourage
* migration to `serialize`/`deserialize`.
*/
const DATE_TAG = "__legacyDate__";
function dateReplacer(
this: Record<string, unknown>,
key: string,
value: unknown
): unknown {
// JSON.stringify calls toJSON() on Date before the replacer sees it,
// so we check `this[key]` to detect the original Date instance.
if (key !== "" && this[key] instanceof Date) {
return { [DATE_TAG]: (this[key] as Date).toISOString() };
}
return value;
}
function dateReviver(_key: string, value: unknown): unknown {
if (
value !== null &&
typeof value === "object" &&
DATE_TAG in (value as Record<string, unknown>)
) {
return new Date((value as Record<string, string>)[DATE_TAG] as string);
}
return value;
}
/**
* Serialize a value using the legacy JSON + Date-only format.
*
* Backwards-compatibility shim — prefer `serialize` from the main entry for new code.
*/
export function legacySerialize(value: unknown): string {
console.warn(
"[serialize/legacy] legacySerialize is deprecated. " +
"Migrate to serialize() from @suaveplan/serialize for full type support."
);
return JSON.stringify(value, dateReplacer);
}
/**
* Deserialize a value produced by `legacySerialize`.
*
* Backwards-compatibility shim — prefer `deserialize` from the main entry for new code.
*/
export function legacyDeserialize(json: string): unknown {
console.warn(
"[serialize/legacy] legacyDeserialize is deprecated. " +
"Migrate to deserialize() from @suaveplan/serialize for full type support."
);
return JSON.parse(json, dateReviver);
}

View File

@@ -0,0 +1,61 @@
# observability/metrics
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the OpenTelemetry instruments used by `@suaveplan/serialize` to emit metrics on every encode/decode call. Built lazily from `metrics.getMeter('suaveplan.serialize')` so the cost is paid once per process. All instruments are no-ops until a `MeterProvider` is registered globally — consumers pay zero overhead unless they wire up an SDK.
The module exposes a single `getSerializeMetrics()` accessor returning a cached `SerializeMetrics` object, plus a `resetSerializeMetrics()` test hook for swapping meter providers between test runs.
## Features
- Six instruments: `encodeBytes`, `decodeBytes`, `payloadBytes` (histograms), `encodeDuration`, `decodeDuration` (histograms), `errorTotal` (counter)
- Stable instrument names (`serialize.encode.bytes`, `serialize.decode.bytes`, `serialize.payload_bytes`, `serialize.encode.duration_ms`, `serialize.decode.duration_ms`, `serialize.error.total`) pinned by spec SZ-11
- Stable label set: `format` (`json` | `msgpack`) on every instrument; `code` additionally on `errorTotal`
- Lazy singleton — first call constructs instruments, subsequent calls return the cached object
- Test-only `resetSerializeMetrics()` for swapping `MeterProvider`s
## Usage: Basic Example
```ts
import { getSerializeMetrics, serialize } from "@suaveplan/serialize";
// First serialize call records duration + bytes histograms
serialize({ hello: "world" });
const metrics = getSerializeMetrics();
// metrics.encodeBytes / metrics.encodeDuration are now backed by
// the global MeterProvider (no-op by default).
```
## Usage: Advanced Example
```ts
import {
getSerializeMetrics,
resetSerializeMetrics,
serialize,
} from "@suaveplan/serialize";
import { metrics, type Meter } from "@opentelemetry/api";
// Wire a custom MeterProvider + reset the singleton so the new provider is used.
metrics.setGlobalMeterProvider(myMeterProvider);
resetSerializeMetrics();
serialize({ x: 1 });
const m = getSerializeMetrics();
// myMeterProvider receives the encode bytes / duration / payload_bytes
// histograms with `{ format: "json" }` labels.
```
## API Reference
- `getSerializeMetrics(): SerializeMetrics` — lazy accessor; first call constructs the meter + instruments.
- `resetSerializeMetrics(): void` — test-only; clears the cache so the next `getSerializeMetrics()` re-resolves the meter.
- `SerializeMetrics` — typed interface exposing `encodeBytes`, `decodeBytes`, `payloadBytes`, `encodeDuration`, `decodeDuration`, `errorTotal`.
## Implementation Notes
The module mirrors the lazy-singleton pattern used by `@suaveplan/i18n` and `@suaveplan/logger`. Names are pinned: `serialize.encode.duration_ms` (per spec SZ-11), `serialize.encode.bytes` / `serialize.decode.bytes` (encode/decode payload sizes by format), `serialize.payload_bytes` (alias retained for backwards-compatibility with the historical spec wording), and `serialize.error.total` (incremented on every thrown error with `{ code, format }` labels). `errorTotal` is the only counter; everything else is a histogram so percentile aggregation is preserved by OTel exporters.

View File

@@ -0,0 +1,234 @@
/**
* Spec-conformance tests for SZ-11 — OTel instrumentation. Pins:
* - Instrumentation is a no-op when no MeterProvider is registered.
* - All required instruments are created with their spec-mandated names.
* - serialize / deserialize / encodeBinary / decodeBinary all dispatch
* histogram + counter calls with the correct `format` label.
*
* Uses a real, in-memory `MeterProvider` implementation that conforms to the
* `@opentelemetry/api` `MeterProvider` interface (not a `vi.mock`). The
* provider records every instrument creation and every measurement call so
* tests can assert on the captured stream.
*/
import {
type Attributes,
type Counter,
type Histogram,
type Meter,
type MeterOptions,
type MeterProvider,
type MetricOptions,
metrics,
} from "@opentelemetry/api";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { deserialize } from "../codec/json/json-decode.js";
import { serialize } from "../codec/json/json-encode.js";
import { decodeBinary } from "../codec/msgpack/msgpack-decode.js";
import { encodeBinary } from "../codec/msgpack/msgpack-encode.js";
import { getSerializeMetrics, resetSerializeMetrics } from "./metrics.js";
interface CounterCall {
name: string;
value: number;
attrs?: Attributes | undefined;
}
interface HistogramCall {
name: string;
value: number;
attrs?: Attributes | undefined;
}
class RecordingHistogram implements Histogram {
public constructor(
public readonly name: string,
private readonly sink: HistogramCall[]
) {}
public record(value: number, attrs?: Attributes): void {
this.sink.push({ name: this.name, value, attrs });
}
}
class RecordingCounter implements Counter {
public constructor(
public readonly name: string,
private readonly sink: CounterCall[]
) {}
public add(value: number, attrs?: Attributes): void {
this.sink.push({ name: this.name, value, attrs });
}
}
class RecordingMeter implements Meter {
public counters: CounterCall[] = [];
public histograms: HistogramCall[] = [];
public createdNames: string[] = [];
public createCounter(name: string, _opts?: MetricOptions): Counter {
this.createdNames.push(name);
return new RecordingCounter(name, this.counters);
}
public createHistogram(name: string, _opts?: MetricOptions): Histogram {
this.createdNames.push(name);
return new RecordingHistogram(name, this.histograms);
}
// Unused — implement minimally to satisfy the interface
public createUpDownCounter(): never {
throw new Error("not used");
}
public createObservableCounter(): never {
throw new Error("not used");
}
public createObservableGauge(): never {
throw new Error("not used");
}
public createObservableUpDownCounter(): never {
throw new Error("not used");
}
public createGauge(): never {
throw new Error("not used");
}
public addBatchObservableCallback(): void {
// no-op
}
public removeBatchObservableCallback(): void {
// no-op
}
}
class RecordingMeterProvider implements MeterProvider {
public meter = new RecordingMeter();
public getMeter(
_name: string,
_version?: string,
_opts?: MeterOptions
): Meter {
return this.meter;
}
}
let provider: RecordingMeterProvider;
beforeEach(() => {
provider = new RecordingMeterProvider();
metrics.setGlobalMeterProvider(provider);
resetSerializeMetrics();
});
afterEach(() => {
metrics.disable();
resetSerializeMetrics();
});
describe("SZ-11 — OTel instrumentation", () => {
it("creates all six instruments with spec-pinned names on first access", {
timeout: 5000,
}, () => {
getSerializeMetrics();
expect(provider.meter.createdNames).toEqual(
expect.arrayContaining([
"serialize.encode.bytes",
"serialize.decode.bytes",
"serialize.payload_bytes",
"serialize.encode.duration_ms",
"serialize.decode.duration_ms",
"serialize.error.total",
])
);
});
it("returns the cached singleton on subsequent calls", {
timeout: 5000,
}, () => {
const a = getSerializeMetrics();
const b = getSerializeMetrics();
expect(a).toBe(b);
});
it("serialize records encode bytes + duration + payload bytes", {
timeout: 5000,
}, () => {
serialize({ hello: "world" });
const names = provider.meter.histograms.map((h) => h.name);
expect(names).toContain("serialize.encode.bytes");
expect(names).toContain("serialize.encode.duration_ms");
expect(names).toContain("serialize.payload_bytes");
const formats = provider.meter.histograms.map(
(h) => h.attrs?.["format"]
);
expect(formats.every((f) => f === "json")).toBe(true);
});
it("deserialize records decode bytes + duration", { timeout: 5000 }, () => {
const json = serialize({ a: 1 });
provider.meter.histograms.length = 0; // clear encode-side calls
deserialize(json);
const names = provider.meter.histograms.map((h) => h.name);
expect(names).toContain("serialize.decode.bytes");
expect(names).toContain("serialize.decode.duration_ms");
});
it("encodeBinary records msgpack-format histograms", {
timeout: 5000,
}, () => {
encodeBinary({ x: 1 });
const formats = provider.meter.histograms
.filter((h) => h.name.startsWith("serialize.encode"))
.map((h) => h.attrs?.["format"]);
expect(formats.every((f) => f === "msgpack")).toBe(true);
});
it("decodeBinary records msgpack-format histograms", {
timeout: 5000,
}, () => {
const bytes = encodeBinary({ x: 1 });
provider.meter.histograms.length = 0;
decodeBinary(bytes);
const names = provider.meter.histograms.map((h) => h.name);
expect(names).toContain("serialize.decode.bytes");
expect(names).toContain("serialize.decode.duration_ms");
const formats = provider.meter.histograms.map(
(h) => h.attrs?.["format"]
);
expect(formats.every((f) => f === "msgpack")).toBe(true);
});
it("serialize.error.total counter increments with code + format labels", {
timeout: 5000,
}, () => {
// Trigger MalformedWireError from JSON deserialize
try {
deserialize("{not json");
} catch {
// expected
}
const errs = provider.meter.counters.filter(
(c) => c.name === "serialize.error.total"
);
expect(errs.length).toBeGreaterThanOrEqual(1);
expect(errs[0]?.attrs?.["format"]).toBe("json");
expect(errs[0]?.attrs?.["code"]).toBe("SERIALIZE.CODEC.MALFORMED_WIRE");
});
it("serialize.error.total fires for msgpack malformed input", {
timeout: 5000,
}, () => {
try {
decodeBinary(new Uint8Array([0xff, 0xff, 0xff, 0xff]));
} catch {
// expected
}
const errs = provider.meter.counters.filter(
(c) =>
c.name === "serialize.error.total" &&
c.attrs?.["format"] === "msgpack"
);
expect(errs.length).toBeGreaterThanOrEqual(1);
});
});

View File

@@ -0,0 +1,79 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
/**
* OpenTelemetry instruments for the @suaveplan/serialize package.
*
* Built lazily from `metrics.getMeter('suaveplan.serialize')`. All
* instruments are no-ops until a `MeterProvider` is registered globally,
* which is the default behaviour of `@opentelemetry/api` — consumers pay
* zero overhead unless they wire up an SDK.
*/
export interface SerializeMetrics {
/** Histogram of bytes produced per encode call; labels: format. */
readonly encodeBytes: Histogram;
/** Histogram of bytes consumed per decode call; labels: format. */
readonly decodeBytes: Histogram;
/**
* Histogram of payload byte sizes (alias of `encodeBytes` for the
* spec-mandated `serialize.payload_bytes` instrument name); labels: format.
*/
readonly payloadBytes: Histogram;
/** Histogram of encode wall-clock duration in ms; labels: format. */
readonly encodeDuration: Histogram;
/** Histogram of decode wall-clock duration in ms; labels: format. */
readonly decodeDuration: Histogram;
/** Counter incremented for every thrown error; labels: code, format. */
readonly errorTotal: Counter;
}
let cached: SerializeMetrics | undefined;
/** Lazily create and cache the serialize metrics singleton. */
export function getSerializeMetrics(): SerializeMetrics {
if (!cached) {
const meter = metrics.getMeter("suaveplan.serialize");
cached = {
encodeBytes: meter.createHistogram("serialize.encode.bytes", {
description:
"Bytes produced per encode call; label: format (json|msgpack)",
unit: "By",
}),
decodeBytes: meter.createHistogram("serialize.decode.bytes", {
description:
"Bytes consumed per decode call; label: format (json|msgpack)",
unit: "By",
}),
payloadBytes: meter.createHistogram("serialize.payload_bytes", {
description:
"Encoded payload size in bytes; label: format (json|msgpack)",
unit: "By",
}),
encodeDuration: meter.createHistogram(
"serialize.encode.duration_ms",
{
description:
"Encode wall-clock duration; label: format (json|msgpack)",
unit: "ms",
}
),
decodeDuration: meter.createHistogram(
"serialize.decode.duration_ms",
{
description:
"Decode wall-clock duration; label: format (json|msgpack)",
unit: "ms",
}
),
errorTotal: meter.createCounter("serialize.error.total", {
description:
"Errors thrown during encode/decode; labels: code, format",
}),
};
}
return cached;
}
/** Test-only: reset the cached singleton (e.g. between meter-provider swaps). */
export function resetSerializeMetrics(): void {
cached = undefined;
}

View File

@@ -0,0 +1,9 @@
export {
getType,
getTypeByCtor,
listTypes,
registerType,
type TypeDefinition,
unregisterType,
} from "./registry";
export { TypeDefinitionSchema } from "./registry.schemas";

View File

@@ -0,0 +1,115 @@
# registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Maintains a global singleton map of type definitions that the JSON and MessagePack codecs consult to encode and decode non-primitive JavaScript values in a round-trip-safe way. Each entry maps a string tag to an encoder/decoder pair and a constructor reference, enabling both forward lookup (value → tag) and reverse lookup (tag → value reconstruction).
The registry ships with 17 built-in entries covering the types most commonly lost by `JSON.stringify`: `Date`, `Map`, `Set`, `BigInt`, the six TypedArray variants, `URL`, `RegExp`, and three special sentinel tags for `undefined`, `NaN`, and `±Infinity`.
## Features
- `registerType(def)` — add a custom type; throws `SERIALIZE.REGISTRY.DUPLICATE_TAG` on tag collision with a different constructor; idempotent for identical re-registrations
- `unregisterType(tag)` — remove by tag; silent if absent (useful for test cleanup)
- `listTypes()` — enumerate all current entries as an array of `TypeDefinition`
- `getType(tag)` — look up by tag string; returns `undefined` if absent
- `getTypeByCtor(ctor)` — look up by constructor; returns `undefined` if absent
- 14 built-in type definitions registered at module load time
- Three special sentinel definitions for `undefined`, `NaN`, and `±Infinity`
## Usage: Basic Example
```typescript
import { registerType, unregisterType } from "@suaveplan/serialize";
class Color {
constructor(public hex: string) {}
}
registerType({
tag: "__Color",
ctor: Color,
encode: (v) => (v as Color).hex,
decode: (enc) => new Color(enc as string),
});
// After registration, serialize/deserialize will handle Color instances.
// Clean up in tests:
unregisterType("__Color");
```
## Usage: Advanced Example
```typescript
import {
getType,
getTypeByCtor,
listTypes,
registerType,
} from "@suaveplan/serialize";
class Vector2 {
constructor(public x: number, public y: number) {}
}
registerType({
tag: "__Vector2",
ctor: Vector2,
encode: (v) => { const vec = v as Vector2; return [vec.x, vec.y]; },
decode: (enc) => { const [x, y] = enc as [number, number]; return new Vector2(x, y); },
});
// Look up by tag
const def = getType("__Vector2");
console.log(def?.tag); // "__Vector2"
// Look up by ctor
const def2 = getTypeByCtor(Vector2);
console.log(def2?.tag); // "__Vector2"
// Enumerate all registered types
const all = listTypes();
console.log(all.map((d) => d.tag));
```
## API Reference
### `registerType(def: TypeDefinition): void`
Registers a custom type. Throws `SerializationError` with code `SERIALIZE.REGISTRY.DUPLICATE_TAG` if the same `tag` string is already registered for a _different_ constructor. Registering the same tag+ctor pair again is a silent no-op (idempotent).
### `unregisterType(tag: string): void`
Removes the registration for `tag` and its associated constructor from both internal maps. Silent if the tag was never registered.
### `listTypes(): TypeDefinition[]`
Returns a snapshot array of all registered `TypeDefinition` objects at the time of the call.
### `getType(tag: string): TypeDefinition | undefined`
Returns the `TypeDefinition` for the given tag, or `undefined` if no entry exists.
### `getTypeByCtor(ctor: Function): TypeDefinition | undefined`
Returns the `TypeDefinition` whose `ctor` reference matches, or `undefined` if none is registered for that constructor.
### `TypeDefinition`
```typescript
interface TypeDefinition {
tag: string;
ctor: Function;
encode: (value: unknown) => unknown;
decode: (encoded: unknown) => unknown;
}
```
## Implementation Notes
- Two parallel `Map` instances (`_byTag` and `_byCtor`) are kept in sync to give O(1) lookup in both directions without requiring full list iteration.
- Built-in registrations use an internal `registerBuiltin` helper that force-sets both maps, making them immune to the duplicate-tag guard that external callers hit. This allows the module to be loaded multiple times in test environments without throwing.
- The sentinel entries for `undefined`, `NaN`, and `±Infinity` use placeholder constructors (`Object`, `Number`, `Boolean`) that will never match a real value via `getTypeByCtor`; they are only reached via the `getType(tag)` path during deserialization.
- TypedArray `Float32Array` and `Float64Array` encode to plain number arrays; precision loss for `Float32Array` is intentional and matches the original in-memory representation.

View File

@@ -0,0 +1,9 @@
import z from "zod";
/** Zod schema for validating a `TypeDefinition` at runtime boundaries. */
export const TypeDefinitionSchema = z.object({
tag: z.string().min(1),
ctor: z.function(),
encode: z.function(),
decode: z.function(),
});

View File

@@ -0,0 +1,308 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { SERIALIZE_CODES } from "../errors/codes.js";
import { SerializationError } from "../errors/serialize-error.js";
import {
getType,
getTypeByCtor,
listTypes,
registerType,
unregisterType,
} from "./registry.js";
const TEST_TAG = "__TestCustomClass";
class TestCustomClass {
public constructor(public value: string) {}
}
afterEach(() => {
unregisterType(TEST_TAG);
});
describe("registerType", () => {
it("registers a new type", { timeout: 5000 }, () => {
registerType({
tag: TEST_TAG,
ctor: TestCustomClass,
encode: (v) => (v as TestCustomClass).value,
decode: (enc) => new TestCustomClass(enc as string),
});
expect(getType(TEST_TAG)).toBeDefined();
});
it("is idempotent for same tag + ctor", { timeout: 5000 }, () => {
const def = {
tag: TEST_TAG,
ctor: TestCustomClass,
encode: (v: unknown) => (v as TestCustomClass).value,
decode: (enc: unknown) => new TestCustomClass(enc as string),
};
registerType(def);
expect(() => registerType(def)).not.toThrow();
});
it("throws DUPLICATE_TAG for same tag + different ctor", {
timeout: 5000,
}, () => {
registerType({
tag: TEST_TAG,
ctor: TestCustomClass,
encode: (v) => v,
decode: (enc) => enc,
});
class OtherClass {}
expect(() =>
registerType({
tag: TEST_TAG,
ctor: OtherClass,
encode: (v) => v,
decode: (enc) => enc,
})
).toThrow(SerializationError);
});
it("error code is REGISTRY.DUPLICATE_TAG", { timeout: 5000 }, () => {
registerType({
tag: TEST_TAG,
ctor: TestCustomClass,
encode: (v) => v,
decode: (enc) => enc,
});
class OtherClass {}
try {
registerType({
tag: TEST_TAG,
ctor: OtherClass,
encode: (v) => v,
decode: (enc) => enc,
});
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(SerializationError);
const se = err as SerializationError;
expect(se.code).toBe(SERIALIZE_CODES["REGISTRY.DUPLICATE_TAG"]);
}
});
});
describe("unregisterType", () => {
it("removes a registered type", { timeout: 5000 }, () => {
registerType({
tag: TEST_TAG,
ctor: TestCustomClass,
encode: (v) => v,
decode: (enc) => enc,
});
unregisterType(TEST_TAG);
expect(getType(TEST_TAG)).toBeUndefined();
});
it("is silent if tag not registered", { timeout: 5000 }, () => {
expect(() => unregisterType("__NonExistent_12345")).not.toThrow();
});
it("removes from ctor index as well", { timeout: 5000 }, () => {
registerType({
tag: TEST_TAG,
ctor: TestCustomClass,
encode: (v) => v,
decode: (enc) => enc,
});
unregisterType(TEST_TAG);
expect(getTypeByCtor(TestCustomClass)).toBeUndefined();
});
});
describe("getType", () => {
it("returns undefined for unknown tag", { timeout: 5000 }, () => {
expect(getType("__NonExistent_99999")).toBeUndefined();
});
it("returns definition for known tag", { timeout: 5000 }, () => {
const def = getType("__Date");
expect(def).toBeDefined();
expect(def?.tag).toBe("__Date");
});
});
describe("getTypeByCtor", () => {
it("returns definition for Date ctor", { timeout: 5000 }, () => {
const def = getTypeByCtor(Date);
expect(def?.tag).toBe("__Date");
});
it("returns undefined for unregistered ctor", { timeout: 5000 }, () => {
class Unregistered {}
expect(getTypeByCtor(Unregistered)).toBeUndefined();
});
});
describe("listTypes", () => {
it("contains all built-in types", { timeout: 5000 }, () => {
const tags = listTypes().map((d) => d.tag);
const expected = [
"__Date",
"__Map",
"__Set",
"__BigInt",
"__Uint8Array",
"__Int8Array",
"__Int16Array",
"__Int32Array",
"__Uint16Array",
"__Uint32Array",
"__Float32Array",
"__Float64Array",
"__URL",
"__RegExp",
"__undefined",
"__NaN",
"__Infinity",
];
for (const tag of expected) {
expect(tags).toContain(tag);
}
});
it("includes custom registered types", { timeout: 5000 }, () => {
registerType({
tag: TEST_TAG,
ctor: TestCustomClass,
encode: (v) => v,
decode: (enc) => enc,
});
const tags = listTypes().map((d) => d.tag);
expect(tags).toContain(TEST_TAG);
});
});
describe("built-in type encode/decode roundtrips", () => {
it("Date", { timeout: 5000 }, () => {
const def = getType("__Date");
const original = new Date("2024-01-15T12:00:00.000Z");
const encoded = def?.encode(original);
const decoded = def?.decode(encoded);
expect(decoded).toBeInstanceOf(Date);
expect((decoded as Date).toISOString()).toBe(original.toISOString());
});
it("Map", { timeout: 5000 }, () => {
const def = getType("__Map");
const original = new Map([
["a", 1],
["b", 2],
]);
const encoded = def?.encode(original);
const decoded = def?.decode(encoded);
expect(decoded).toBeInstanceOf(Map);
expect((decoded as Map<string, number>).get("a")).toBe(1);
});
it("Set", { timeout: 5000 }, () => {
const def = getType("__Set");
const original = new Set([1, 2, 3]);
const encoded = def?.encode(original);
const decoded = def?.decode(encoded);
expect(decoded).toBeInstanceOf(Set);
expect((decoded as Set<number>).has(2)).toBe(true);
});
it("BigInt", { timeout: 5000 }, () => {
const def = getType("__BigInt");
const original = BigInt("9007199254740993");
const encoded = def?.encode(original);
const decoded = def?.decode(encoded);
expect(decoded).toBe(original);
});
it("Uint8Array", { timeout: 5000 }, () => {
const def = getType("__Uint8Array");
const original = new Uint8Array([1, 2, 255]);
const encoded = def?.encode(original);
const decoded = def?.decode(encoded);
expect(decoded).toBeInstanceOf(Uint8Array);
expect(Array.from(decoded as Uint8Array)).toEqual([1, 2, 255]);
});
it("URL", { timeout: 5000 }, () => {
const def = getType("__URL");
const original = new URL("https://example.com/path?q=1");
const encoded = def?.encode(original);
const decoded = def?.decode(encoded);
expect(decoded).toBeInstanceOf(URL);
expect((decoded as URL).href).toBe(original.href);
});
it("RegExp", { timeout: 5000 }, () => {
const def = getType("__RegExp");
const original = /hello/gi;
const encoded = def?.encode(original);
const decoded = def?.decode(encoded);
expect(decoded).toBeInstanceOf(RegExp);
expect((decoded as RegExp).source).toBe("hello");
expect((decoded as RegExp).flags).toBe("gi");
});
it("__undefined decodes to undefined", { timeout: 5000 }, () => {
const def = getType("__undefined");
expect(def?.decode(null)).toBeUndefined();
});
it("__NaN decodes to NaN", { timeout: 5000 }, () => {
const def = getType("__NaN");
expect(Number.isNaN(def?.decode(null) as number)).toBe(true);
});
it("__Infinity decodes +Infinity", { timeout: 5000 }, () => {
const def = getType("__Infinity");
expect(def?.decode("+")).toBe(Number.POSITIVE_INFINITY);
});
it("__Infinity decodes -Infinity", { timeout: 5000 }, () => {
const def = getType("__Infinity");
expect(def?.decode("-")).toBe(Number.NEGATIVE_INFINITY);
});
it("__Infinity encodes +/-Infinity", { timeout: 5000 }, () => {
const def = getType("__Infinity");
expect(def?.encode(Number.POSITIVE_INFINITY)).toBe("+");
expect(def?.encode(Number.NEGATIVE_INFINITY)).toBe("-");
});
it("__undefined encodes to null", { timeout: 5000 }, () => {
const def = getType("__undefined");
expect(def?.encode(undefined)).toBeNull();
});
it("__NaN encodes to null", { timeout: 5000 }, () => {
const def = getType("__NaN");
expect(def?.encode(Number.NaN)).toBeNull();
});
});
describe("typed array roundtrips", () => {
const cases: [string, new (arr: number[]) => ArrayLike<number>][] = [
["__Int8Array", Int8Array],
["__Int16Array", Int16Array],
["__Int32Array", Int32Array],
["__Uint16Array", Uint16Array],
["__Uint32Array", Uint32Array],
["__Float32Array", Float32Array],
["__Float64Array", Float64Array],
];
for (const [tag, Ctor] of cases) {
it(`${tag} roundtrip`, { timeout: 5000 }, () => {
const def = getType(tag);
const original = new Ctor([1, 2, 3]);
const encoded = def?.encode(original);
const decoded = def?.decode(encoded);
expect(decoded).toBeInstanceOf(Ctor);
expect(Array.from(decoded as ArrayLike<number>)).toEqual(
Array.from(original)
);
});
}
});

View File

@@ -0,0 +1,218 @@
import { SERIALIZE_CODES } from "../errors/codes.js";
import { SerializationError } from "../errors/serialize-error.js";
import type { TypeDefinition } from "./registry.types.js";
export type { TypeDefinition };
// ---------------------------------------------------------------------------
// Global singleton registry
// ---------------------------------------------------------------------------
const _byTag = new Map<string, TypeDefinition>();
// biome-ignore lint/complexity/noBannedTypes: intentional Function key for ctor lookup
const _byCtor = new Map<Function, TypeDefinition>();
/**
* Register a custom type for round-trip serialization.
*
* Throws `SERIALIZE.REGISTRY.DUPLICATE_TAG` if the tag is already registered
* for a *different* constructor (re-registering the same tag+ctor is a no-op).
*/
export function registerType(def: TypeDefinition): void {
const existing = _byTag.get(def.tag);
if (existing !== undefined && existing.ctor !== def.ctor) {
throw new SerializationError(
SERIALIZE_CODES["REGISTRY.DUPLICATE_TAG"],
"serialize.registry.duplicate_tag",
{ context: { tag: def.tag } }
);
}
_byTag.set(def.tag, def);
_byCtor.set(def.ctor, def);
}
/** Remove a previously registered type by tag. Silent if not found. */
export function unregisterType(tag: string): void {
const existing = _byTag.get(tag);
if (existing !== undefined) {
_byCtor.delete(existing.ctor);
_byTag.delete(tag);
}
}
/** Return all currently registered `TypeDefinition` objects. */
export function listTypes(): TypeDefinition[] {
return Array.from(_byTag.values());
}
/** Look up a `TypeDefinition` by its tag string, or `undefined` if absent. */
export function getType(tag: string): TypeDefinition | undefined {
return _byTag.get(tag);
}
/** Look up a `TypeDefinition` by constructor reference, or `undefined`. */
// biome-ignore lint/complexity/noBannedTypes: intentional Function for ctor lookup
export function getTypeByCtor(ctor: Function): TypeDefinition | undefined {
return _byCtor.get(ctor);
}
// ---------------------------------------------------------------------------
// Built-in type registrations
// ---------------------------------------------------------------------------
function registerBuiltin(def: TypeDefinition, skipCtorIndex = false): void {
// Built-ins always win; force-set even if already present (idempotent).
_byTag.set(def.tag, def);
if (!skipCtorIndex) {
_byCtor.set(def.ctor, def);
}
}
registerBuiltin({
tag: "__Date",
ctor: Date,
encode: (v) => (v as Date).toISOString(),
decode: (enc) => new Date(enc as string),
});
registerBuiltin({
tag: "__Map",
ctor: Map,
encode: (v) => Array.from((v as Map<unknown, unknown>).entries()),
decode: (enc) => new Map(enc as [unknown, unknown][]),
});
registerBuiltin({
tag: "__Set",
ctor: Set,
encode: (v) => Array.from((v as Set<unknown>).values()),
decode: (enc) => new Set(enc as unknown[]),
});
registerBuiltin({
tag: "__BigInt",
ctor: BigInt,
encode: (v) => (v as bigint).toString(),
decode: (enc) => BigInt(enc as string),
});
registerBuiltin({
tag: "__Uint8Array",
ctor: Uint8Array,
encode: (v) =>
Array.from(v as Uint8Array)
.map((b) => b.toString(16).padStart(2, "0"))
.join(""),
decode: (enc) => {
const hex = enc as string;
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
},
});
registerBuiltin({
tag: "__Int8Array",
ctor: Int8Array,
encode: (v) => Array.from(v as Int8Array),
decode: (enc) => new Int8Array(enc as number[]),
});
registerBuiltin({
tag: "__Int16Array",
ctor: Int16Array,
encode: (v) => Array.from(v as Int16Array),
decode: (enc) => new Int16Array(enc as number[]),
});
registerBuiltin({
tag: "__Int32Array",
ctor: Int32Array,
encode: (v) => Array.from(v as Int32Array),
decode: (enc) => new Int32Array(enc as number[]),
});
registerBuiltin({
tag: "__Uint16Array",
ctor: Uint16Array,
encode: (v) => Array.from(v as Uint16Array),
decode: (enc) => new Uint16Array(enc as number[]),
});
registerBuiltin({
tag: "__Uint32Array",
ctor: Uint32Array,
encode: (v) => Array.from(v as Uint32Array),
decode: (enc) => new Uint32Array(enc as number[]),
});
registerBuiltin({
tag: "__Float32Array",
ctor: Float32Array,
encode: (v) => Array.from(v as Float32Array),
decode: (enc) => new Float32Array(enc as number[]),
});
registerBuiltin({
tag: "__Float64Array",
ctor: Float64Array,
encode: (v) => Array.from(v as Float64Array),
decode: (enc) => new Float64Array(enc as number[]),
});
registerBuiltin({
tag: "__URL",
ctor: URL,
encode: (v) => (v as URL).href,
decode: (enc) => new URL(enc as string),
});
registerBuiltin({
tag: "__RegExp",
ctor: RegExp,
encode: (v) => {
const re = v as RegExp;
return { source: re.source, flags: re.flags };
},
decode: (enc) => {
const { source, flags } = enc as { source: string; flags: string };
return new RegExp(source, flags);
},
});
// Special sentinel registrations — these are detected by value, not by ctor.
// We register tag-only entries (skipCtorIndex=true) so getType() can return
// a decode handler without polluting the ctor index.
registerBuiltin(
{
tag: "__undefined",
ctor: Object, // placeholder; never used for ctor lookup
encode: (_v) => null,
decode: (_enc) => undefined,
},
true // skip ctor index
);
registerBuiltin(
{
tag: "__NaN",
ctor: Object, // placeholder; never used for ctor lookup
encode: (_v) => null,
decode: (_enc) => Number.NaN,
},
true // skip ctor index
);
registerBuiltin(
{
tag: "__Infinity",
ctor: Object, // placeholder; never used for ctor lookup
encode: (v) => (v === Number.POSITIVE_INFINITY ? "+" : "-"),
decode: (enc) =>
enc === "+" ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY,
},
true // skip ctor index
);

View File

@@ -0,0 +1,16 @@
/**
* Describes a custom type that can be round-tripped through the serialization
* codec. `encode` converts an instance to a plain, JSON-safe intermediate
* value; `decode` reconstructs an instance from that intermediate.
*/
export interface TypeDefinition {
/** Unique string tag used as the `__t` discriminator in wire format. */
tag: string;
/** The constructor function used for `getTypeByCtor` lookup. */
// biome-ignore lint/complexity/noBannedTypes: intentional Function for ctor lookup
ctor: Function;
/** Convert a live instance to a JSON-safe intermediate value. */
encode: (value: unknown) => unknown;
/** Reconstruct a live instance from the intermediate value. */
decode: (encoded: unknown) => unknown;
}

View File

@@ -0,0 +1,21 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph: runtime @suaveplan/* deps", () => {
it("only depends on @suaveplan/error and @suaveplan/types", {
timeout: 5000,
}, () => {
const pkgPath = join(import.meta.dirname, "..", "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
dependencies?: Record<string, string>;
};
const suaveDeps = Object.keys(pkg.dependencies ?? {})
.filter((dep) => dep.startsWith("@suaveplan/"))
.sort();
expect(suaveDeps).toEqual(
["@suaveplan/error", "@suaveplan/types"].sort()
);
});
});

View File

@@ -0,0 +1,69 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const DISALLOWED_PATTERNS = [
"node:fs",
"node:path",
"node:url",
"process.env",
"Buffer",
"document.",
"window.",
];
// Allow ReadableStream (Web Streams API — explicitly permitted)
function walkDir(dir: string, results: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (
entry !== "__benchmarks__" &&
entry !== "node_modules" &&
entry !== "dist"
) {
walkDir(full, results);
}
} else if (
full.endsWith(".ts") &&
!full.endsWith(".test.ts") &&
!full.endsWith(".bench.ts") &&
!full.endsWith(".types.ts")
) {
results.push(full);
}
}
return results;
}
describe("no-disallowed: source files must not use banned APIs", () => {
it("source files do not use node:fs, node:path, node:url, process.env, Buffer, document, window", {
timeout: 10000,
}, () => {
const srcDir = join(import.meta.dirname, "..", "src");
const files = walkDir(srcDir);
const violations: string[] = [];
for (const file of files) {
const content = readFileSync(file, "utf-8");
const relativePath = file.split("/src/")[1] ?? file;
for (const pattern of DISALLOWED_PATTERNS) {
if (content.includes(pattern)) {
violations.push(`${relativePath}: contains "${pattern}"`);
}
}
}
if (violations.length > 0) {
throw new Error(
`Disallowed patterns found in source:\n${violations.join("\n")}`
);
}
// At least checked one file
expect(files.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,27 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("tree-shake: main bundle isolation", () => {
it("package.json has sideEffects: false", { timeout: 5000 }, () => {
const pkgPath = join(import.meta.dirname, "..", "package.json");
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
sideEffects?: boolean;
};
expect(pkg.sideEffects).toBe(false);
});
it("dist/index.js does not reference serialize-javascript", {
timeout: 5000,
}, () => {
const distPath = join(import.meta.dirname, "..", "dist", "index.js");
let content: string;
try {
content = readFileSync(distPath, "utf-8");
} catch {
// Build hasn't run yet in test-only mode — skip gracefully.
return;
}
expect(content).not.toContain("serialize-javascript");
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,45 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/serialize",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"adapters/index": "src/adapters/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
"@msgpack/msgpack",
"serialize-javascript",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,40 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"src/constants/**",
"src/error-codes.ts",
"src/**/*.schemas.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});