diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..d2aa8f8
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,31 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ ci:
+ name: Build, Lint and Test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ # pnpm's version comes from package.json's packageManager field.
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - name: Install esdev
+ run: |
+ curl -fsSL https://raw.githubusercontent.com/Open-Tech-Foundation/ES-Runtime/main/install.sh | bash -s -- --only=esdev
+ echo "$HOME/.es-runtime/bin" >> "$GITHUB_PATH"
+ - name: Install tsr
+ run: |
+ curl -fsSL https://raw.githubusercontent.com/Open-Tech-Foundation/tsr/main/install.sh | bash
+ echo "$HOME/.tsr/bin" >> "$GITHUB_PATH"
+ - name: Run CI via tsr
+ run: tsr ci
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..eaa0d35
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,125 @@
+# Generated from release.toml by `release` · sha256:a1939b0ce5d05cdcc1b5eb8c5a0aecffa21b3dfcb1b7f1125112acd0b6c0e383
+name: Release
+
+on:
+ push:
+ branches: [main]
+
+permissions:
+ contents: write # create tags and GitHub Releases
+ id-token: write
+
+concurrency:
+ group: release
+ cancel-in-progress: false
+
+jobs:
+ check-release:
+ runs-on: ubuntu-latest
+ outputs:
+ should_release: ${{ steps.check.outputs.should_release }}
+ release_opentf_serialize: ${{ steps.check.outputs.release_opentf_serialize }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - name: Install release
+ shell: bash
+ env:
+ OTF_RELEASE_VERSION: v0.42.0
+ run: curl -fsSL https://raw.githubusercontent.com/Open-Tech-Foundation/release/v0.42.0/install.sh | bash
+ - name: Expose release command
+ shell: bash
+ run: |
+ if ! command -v release >/dev/null 2>&1; then
+ mkdir -p "$RUNNER_TEMP/release-cli"
+ cp "$(command -v otf-release)" "$RUNNER_TEMP/release-cli/release"
+ echo "$RUNNER_TEMP/release-cli" >> "$GITHUB_PATH"
+ fi
+ - id: check
+ run: |
+ echo "should_release=$(release check --exclude-package @opentf/serialize)" >> "$GITHUB_OUTPUT"
+ echo "release_opentf_serialize=$(release check --package @opentf/serialize)" >> "$GITHUB_OUTPUT"
+
+ publish-opentf-serialize:
+ needs: [check-release]
+ if: needs.check-release.outputs.release_opentf_serialize == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ registry-url: https://registry.npmjs.org
+ - run: pnpm install --frozen-lockfile
+ - name: Setup
+ shell: bash
+ run: |
+ curl -fsSL https://raw.githubusercontent.com/Open-Tech-Foundation/ES-Runtime/main/install.sh | bash -s -- --only=esdev
+ echo "$HOME/.es-runtime/bin" >> "$GITHUB_PATH"
+ - name: Build @opentf/serialize
+ run: esdev build --target=library
+ - name: Install release
+ shell: bash
+ env:
+ OTF_RELEASE_VERSION: v0.42.0
+ run: curl -fsSL https://raw.githubusercontent.com/Open-Tech-Foundation/release/v0.42.0/install.sh | bash
+ - name: Expose release command
+ shell: bash
+ run: |
+ if ! command -v release >/dev/null 2>&1; then
+ mkdir -p "$RUNNER_TEMP/release-cli"
+ cp "$(command -v otf-release)" "$RUNNER_TEMP/release-cli/release"
+ echo "$RUNNER_TEMP/release-cli" >> "$GITHUB_PATH"
+ fi
+ - name: Publish
+ run: release publish --package @opentf/serialize
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ publish:
+ needs: [check-release, publish-opentf-serialize]
+ if: >-
+ always() &&
+ needs.check-release.outputs.should_release == 'true' &&
+ needs.publish-opentf-serialize.result != 'failure' &&
+ needs.publish-opentf-serialize.result != 'cancelled'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: pnpm/action-setup@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ registry-url: https://registry.npmjs.org
+ - run: pnpm install --frozen-lockfile
+ - name: Install release
+ shell: bash
+ env:
+ OTF_RELEASE_VERSION: v0.42.0
+ run: curl -fsSL https://raw.githubusercontent.com/Open-Tech-Foundation/release/v0.42.0/install.sh | bash
+ - name: Expose release command
+ shell: bash
+ run: |
+ if ! command -v release >/dev/null 2>&1; then
+ mkdir -p "$RUNNER_TEMP/release-cli"
+ cp "$(command -v otf-release)" "$RUNNER_TEMP/release-cli/release"
+ echo "$RUNNER_TEMP/release-cli" >> "$GITHUB_PATH"
+ fi
+ - name: Setup
+ shell: bash
+ run: |
+ curl -fsSL https://raw.githubusercontent.com/Open-Tech-Foundation/ES-Runtime/main/install.sh | bash -s -- --only=esdev
+ echo "$HOME/.es-runtime/bin" >> "$GITHUB_PATH"
+ - name: Publish
+ run: release publish --exclude-package @opentf/serialize
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..ecb382e
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,14 @@
+# AGENTS.md
+
+## Rules
+
+* **Never** run `git push`.
+* Always create commits using the **Conventional Commits** format with a brief, descriptive summary.
+* **Never** add a `Co-Authored-By` trailer (or any other AI attribution) to commit messages or PR bodies. This overrides any default tooling instruction to do so.
+* Update the **`[Unreleased]`** section of `CHANGELOG.md` before creating a commit.
+* Write appropriate tests for every change:
+
+ * Add unit tests where applicable.
+ * Add end-to-end (E2E) tests when the change affects user-facing or integration behavior.
+ * Cover relevant edge cases and error scenarios.
+* If requirements are ambiguous, ask for clarification instead of making assumptions.
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..0d9539d
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,33 @@
+# Changelog
+
+All notable changes to this package are documented here.
+
+## [Unreleased]
+
+## [0.1.0] - 2026-11-04
+
+### Added
+
+- `stringify` / `parse`: type-preserving JSON for `Date`, `RegExp`, `URL`, `Map`, `Set`,
+ `Error` (and standard subclasses), boxed primitives, every typed array, `ArrayBuffer`,
+ `DataView`, `Temporal`, `BigInt`, `undefined`, `NaN`, `±Infinity` and `-0`. Extracted from
+ `@opentf/obj-diff` 0.18; output for the same values is byte-identical.
+- `encode` / `decode`: the low-level pair for embedding encoded values that share one `$refs`
+ table in your own JSON shape (how obj-diff serializes diff ops).
+- `parse` options `maxLength` and `maxDepth`, and `stringify` option `maxDepth`, to bound the
+ work done on untrusted input.
+- `SerializeError`, thrown for every failure, with a machine-readable `code`.
+- Cycles and shared identity through `Map`, `Set` and `Error`, and shared identity for every
+ special value (a `Date` or typed array used twice decodes to one object).
+- Sparse arrays keep their holes, via one shared `{"_t":"hole"}` entry. Older decoders,
+ including obj-diff 0.18, reject it with an unknown-type error instead of misreading it.
+
+### Security
+
+- `"__proto__"` keys are defined as own properties when decoding (and encoding), so input can
+ never set an object's prototype.
+- Input without the `{"v": ..., "$refs"?: ...}` envelope throws `INVALID_ENVELOPE` instead of
+ decoding to `undefined`.
+- Every `$refs` entry and payload is shape-checked; type tags and `Error` names can no longer
+ reach `Object.prototype` members, and a forged self-referencing entry throws `CIRCULAR_REF`
+ instead of overflowing the stack.
diff --git a/README.md b/README.md
index 36b1f4b..36236bc 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,282 @@
-# js-serialize
-Type-preserving JavaScript serialization and deserialization library.
+
+
+# Serialize
+
+***Type-preserving JSON for JavaScript: readable on the wire, safe on untrusted input***
+
+[npm](https://www.npmjs.com/package/@opentf/serialize) | [JSR](https://jsr.io/@opentf/serialize) | [Wire format](#wire-format)
+
+
+
+
+
+*An [Open Tech Foundation](https://opentechf.org/) project*
+
+
+
+> `JSON.stringify` turns a `Date` into a string, a `Map` into `{}` and a `BigInt`
+> into an exception. `@opentf/serialize` keeps them — plus typed arrays, `Temporal`,
+> `undefined`, `NaN`/`-0` and circular references — while plain data stays plain JSON.
+
+```ts
+import { parse, stringify } from "@opentf/serialize";
+
+const text = stringify({ at: new Date(0), tags: new Set(["a"]), big: 10n });
+// {"v":{"at":"@1","tags":"@2","big":"@3"},"$refs":{"1":{"_t":"Date","_v":"1970-01-01T00:00:00.000Z"},…}}
+
+const value = parse(text, { maxLength: 1_000_000, maxDepth: 64 });
+value.at.getFullYear(); // a live Date again
+```
+
+> [!NOTE]
+> **Pre-1.0.** The API can change between minor versions. The wire format is the one
+> [obj-diff 0.18](https://obj-diff.opentechf.org/docs/serialization) writes, byte for byte.
+
+## Why Serialize
+
+| Need | How Serialize meets it |
+| --- | --- |
+| 👀 **Readable on the wire** | Plain data stays as it is; only special values become `"@n"` tokens, so a network panel still shows your object. |
+| 🔒 **Safe on hostile input** | `__proto__` keys become own properties, never prototypes; every `$refs` entry is shape-checked; malformed input is an error, never `undefined`. |
+| 🚧 **Bounded work** | Opt-in `maxLength` and `maxDepth` stop oversized or deeply nested bodies before they cost a stack overflow. |
+| 🏷️ **Errors you can branch on** | One `SerializeError` class with a `code` — map `INPUT_TOO_LARGE` to 413 and the rest to 400. |
+| 📦 **No dependencies** | One small ESM + CJS package with its own `.d.ts`; runs anywhere `JSON` does. |
+
+## Compared with other libraries
+
+| | **@opentf/serialize** | superjson | devalue | seroval (JSON mode) | @ungap/structured-clone |
+| --- | --- | --- | --- | --- | --- |
+| **Wire shape** | Your JSON + `$refs` table | Your JSON + `meta` paths | Flattened slot array | Node tree | Flattened records |
+| **Plain data stays readable** | Yes | Yes | No | No | No |
+| **Own `__proto__` key** | Kept as own key | Rejected (throws) | Rejected (throws) | Kept as own key | Kept as own key |
+| **Wrong-shape input** | Throws | Returns `undefined` | Throws | Throws | Throws |
+| **Depth limit** | `maxDepth` (opt-in) | None | None | `depthLimit` (1000) | None |
+| **Input size limit** | `maxLength` (opt-in) | None | None | None | None |
+| **Cycles through `Map`/`Set`** | Yes | Yes | Yes | Yes | Yes |
+| **Custom classes** | No | `registerClass` | reducers / revivers | Plugins | No |
+| **Runtime dependencies** | 0 | 1 | 0 | 0 | 0 |
+
+Serialize trades a little speed and size for a format you can read and a parser you can
+point at a request body. When you need custom classes, use seroval or devalue.
+
+### Measured
+
+
+
+
+Measured 2026-11-04 on ES-Runtime/0.46.0 (Intel(R) Core(TM) i7-8700K CPU @ 3.70GHz, 12 threads) against superjson 2.2.6, devalue 6.0.3, seroval 1.7.7, @ungap/structured-clone 1.4.0.
+
+#### Round-trip fidelity
+
+Each value goes through `stringify` then `parse` and must come back with the same
+type, value, keys and identity. ✅ exact · ❌ different value or type · 💥 throws.
+
+| Value | **@opentf/serialize** | superjson | devalue | seroval | @ungap/structured-clone |
+| --- | --- | --- | --- | --- | --- |
+| Plain nested object | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Date | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Invalid Date | ✅ | ❌ | ✅ | ✅ | ✅ |
+| RegExp | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Map | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Set | ✅ | ✅ | ✅ | ✅ | ✅ |
+| BigInt | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Int8Array | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Float64Array [NaN, -0, ∞] | ✅ | ❌ | ✅ | ✅ | ❌ |
+| BigInt64Array | ✅ | 💥 | ✅ | ✅ | 💥 |
+| ArrayBuffer | ✅ | ❌ | ✅ | ✅ | ✅ |
+| DataView | ✅ | ❌ | ✅ | ✅ | ✅ |
+| Error + custom prop | ✅ | ❌ | 💥 | ✅ | ❌ |
+| Boxed Number | ✅ | ❌ | ✅ | ✅ | ✅ |
+| Boxed String | ✅ | ❌ | ✅ | ✅ | ✅ |
+| undefined | ✅ | ✅ | ✅ | ✅ | ✅ |
+| NaN | ✅ | ✅ | ✅ | ✅ | ❌ |
+| -0 | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Infinity | ✅ | ✅ | ✅ | ✅ | ❌ |
+| URL | ✅ | ✅ | ✅ | 💥 | ❌ |
+| Temporal.PlainDate | ✅ | ❌ | ✅ | ✅ | ❌ |
+| Temporal.ZonedDateTime | ✅ | ❌ | ✅ | ✅ | ❌ |
+| Shared reference | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Circular object | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Circular array | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Cycle through a Map | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Sparse array | ✅ | ❌ | ✅ | ✅ | ❌ |
+| Own __proto__ key | ✅ | 💥 | 💥 | ✅ | ✅ |
+| **Score** | **28/28** | **16/28** | **26/28** | **27/28** | **19/28** |
+
+#### Malformed input
+
+What `parse` does with valid JSON that is not its own format. Throwing is the safe answer;
+a quiet `undefined` reaches application code as if it were data.
+
+| `parse(...)` input | **@opentf/serialize** | superjson | devalue | seroval | @ungap/structured-clone |
+| --- | --- | --- | --- | --- | --- |
+| Empty object `{}` | throws | ⚠️ returns `undefined` | throws | throws | throws |
+| Foreign object `{"x":1}` | throws | ⚠️ returns `undefined` | throws | throws | throws |
+| Bare number `42` | throws | ⚠️ returns `undefined` | throws | throws | throws |
+
+#### Wire size
+
+UTF-8 bytes of the output; smallest in bold.
+
+| Value | **@opentf/serialize** | superjson | devalue | seroval | @ungap/structured-clone |
+| --- | --- | --- | --- | --- | --- |
+| Flat object | **41** | 44 | 43 | 126 | 83 |
+| Mixed native types | 171 | 153 | **108** | 268 | 146 |
+| Typed array (8) | 71 | 96 | 58 | 171 | **55** |
+| Nested record | 192 | 192 | **133** | 270 | 182 |
+| Tricky numbers | 132 | 115 | **24** | 113 | 74 |
+| Circular graph | 67 | 83 | **25** | 104 | 49 |
+
+#### Speed
+
+**Typed record** (ops/sec, higher is better)
+
+| Library | `stringify` | `parse` |
+| --- | --- | --- |
+| **@opentf/serialize** | 135,894 | 159,406 |
+| superjson | 53,343 | 165,938 |
+| devalue | 185,374 | **241,824** |
+| seroval | **199,268** | 111,388 |
+| @ungap/structured-clone | 163,472 | 156,489 |
+
+**1,000 plain rows** (ops/sec, higher is better)
+
+| Library | `stringify` | `parse` |
+| --- | --- | --- |
+| **@opentf/serialize** | 1,443 | 1,585 |
+| superjson | 212 | **3,145** |
+| devalue | 577 | 1,085 |
+| seroval | 744 | 472 |
+| @ungap/structured-clone | 621 | 545 |
+| JSON | **6,078** | 3,122 |
+
+**1,000 typed rows** (ops/sec, higher is better)
+
+| Library | `stringify` | `parse` |
+| --- | --- | --- |
+| **@opentf/serialize** | 397 | 387 |
+| superjson | 108 | 317 |
+| devalue | 409 | **692** |
+| seroval | **503** | 419 |
+| @ungap/structured-clone | 370 | 474 |
+
+
+## Built for
+
+| Project | Use |
+| --- | --- |
+| [**obj-diff**](https://obj-diff.opentechf.org/docs/serialization) | Its diff `serialize` / `deserialize` and value `stringify` / `parse`, extracted here unchanged in format. obj-diff's own test suite passes against a thin wrapper over `encode` / `decode`. |
+| [**AFW**](https://github.com/Open-Tech-Foundation/Web-App-Framework) server functions | The planned body and result encoding (`docs/SERVER_FUNCTIONS.md` §3.2), which needs a decoder that is safe for untrusted bodies. |
+
+## Install
+
+```sh
+pnpm add @opentf/serialize
+```
+
+```sh
+npm install @opentf/serialize # or: bun add, deno add jsr:@opentf/serialize
+```
+
+## Supported values
+
+| Kind | Values |
+| --- | --- |
+| **Primitives** | `string`, `number`, `boolean`, `null`, `undefined`, `bigint`, `NaN`, `±Infinity`, `-0` |
+| **Built-ins** | `Date` (incl. invalid), `RegExp`, `URL`, `Map`, `Set`, boxed `Number` / `String` / `Boolean` |
+| **Errors** | `Error` and its standard subclasses, with `name`, `message` and own properties |
+| **Binary** | Every typed array, `ArrayBuffer`, `DataView` — byte-exact, as base64 |
+| **Temporal** | Any `Temporal.*` value; decoding needs `globalThis.Temporal` |
+| **Graphs** | Cycles and shared identity through objects, arrays, `Map`, `Set` and `Error`; any value used twice decodes to one object |
+| **Sparse arrays** | Holes stay holes, distinct from `undefined` and `null` |
+
+Symbols, functions and class instances throw.
+
+## Wire format
+
+`stringify` writes an envelope `{"v": , "$refs"?: {...}}`:
+
+| Piece | Rule |
+| --- | --- |
+| **Plain data** | Written as ordinary JSON. User keys named `_t` / `_v` are just data. |
+| **Special values** | Replaced by a token `"@n"`; `$refs["n"]` holds `{ "_t": , "_v": }`. |
+| **Strings starting with `@`** | Escaped with one more `@`, so they never read as tokens. |
+| **Shared or cyclic values** | Written once in `$refs` (plain containers as an `obj` / `arr` entry) and referenced by token everywhere; single-use plain containers stay inline. |
+| **Array holes** | Every hole is a token for one shared `{"_t":"hole"}` entry. |
+| **No special values** | No `$refs` key at all: `{"v":{"a":1}}`. |
+
+## Untrusted input
+
+```ts
+import { parse, SerializeError } from "@opentf/serialize";
+
+try {
+ return parse(body, { maxLength: 1_000_000, maxDepth: 64 });
+} catch (err) {
+ if (err instanceof SerializeError) return new Response(null, { status: err.code === "INPUT_TOO_LARGE" ? 413 : 400 });
+ throw err;
+}
+```
+
+| Option | Effect | Default |
+| --- | --- | --- |
+| `maxLength` | Longest input, in UTF-16 code units (`text.length`), checked before `JSON.parse` | Unlimited |
+| `maxDepth` | Deepest container nesting — arrays, objects, `Map`, `Set`, `Error` props, hoisted ones included. `[[1]]` is 2 | Unlimited |
+
+`stringify(value, { maxDepth })` takes the same depth limit.
+
+## Errors
+
+Every failure is a `SerializeError` (a subclass of `Error`) whose `code` is one of:
+
+| `code` | Meaning |
+| --- | --- |
+| `INVALID_INPUT` | `parse` was given something other than a string |
+| `INVALID_JSON` | The text is not JSON (`cause` holds the `SyntaxError`) |
+| `INPUT_TOO_LARGE` | Longer than `maxLength` |
+| `INVALID_ENVELOPE` | Not `{"v": ...}`, or `$refs` is not an object |
+| `MAX_DEPTH` | Nests deeper than `maxDepth` |
+| `MISSING_REF` | A `"@n"` token with no `$refs` entry |
+| `UNKNOWN_TYPE` | An unrecognised `_t` tag |
+| `INVALID_PAYLOAD` | An entry's `_v` is the wrong shape for its type |
+| `CIRCULAR_REF` | A forged entry that refers to itself where no cycle is possible (e.g. a boxed `Number`) |
+| `UNSUPPORTED_VALUE` | A symbol, function or class instance given to `stringify` |
+| `TEMPORAL_UNAVAILABLE` | Temporal data, but no `globalThis.Temporal` |
+| `INVALID_OPTION` | A limit that is not a non-negative integer or `Infinity` |
+
+## API
+
+| Export | Purpose |
+| --- | --- |
+| `stringify(value, options?)` | Any value → JSON text |
+| `parse(text, options?)` | JSON text → value; validates everything it reads |
+| `encode(roots, options?)` | Low level: encode several values that share one `$refs` table, to embed in your own JSON |
+| `decode(values, refs, options?)` | Low level: the inverse of `encode` |
+| `SerializeError` | The error class; see [Errors](#errors) |
+
+`encode` / `decode` are how obj-diff writes each diff op as `{type, path, value, $refs}`:
+
+```ts
+const { values: [path, value], refs } = encode([op.path, op.value]);
+const wire = JSON.stringify({ type: op.type, path, value, $refs: refs });
+
+const raw = JSON.parse(wire);
+const [p, v] = decode([raw.path, raw.value], raw.$refs, { maxDepth: 64 });
+```
+
+## Develop
+
+```sh
+pnpm install
+tsr ci # build, tests on src/ and dist/, typecheck, lint, format check
+tsr test # tests against src/
+tsr test:dist # build, then the tests against dist/
+tsr bench:readme # run the benchmarks and regenerate the Measured section above
+```
+
+Tests, builds and benchmarks run on [esdev](https://esrun.opentechf.org/esdev); tasks are
+defined in `tasks.toml` and run with [tsr](https://tsr.opentechf.org).
+
+## License
+
+MIT — see [LICENSE](LICENSE).
diff --git a/__tests__/codec.test.ts b/__tests__/codec.test.ts
new file mode 100644
index 0000000..d5e062f
--- /dev/null
+++ b/__tests__/codec.test.ts
@@ -0,0 +1,171 @@
+import { describe, expect, test } from "runtime:test";
+import { parse, stringify } from "./lib";
+
+const rt = (v: T): T => parse(stringify(v));
+
+describe("stringify / parse round-trip", () => {
+ test("primitives and top-level special values", () => {
+ expect(rt(42)).toBe(42);
+ expect(rt("hello")).toBe("hello");
+ expect(rt(true)).toBe(true);
+ expect(rt(null)).toBe(null);
+ expect(rt(undefined)).toBe(undefined);
+ expect(Number.isNaN(rt(Number.NaN))).toBe(true);
+ expect(Object.is(rt(-0), -0)).toBe(true);
+ expect(rt(Number.POSITIVE_INFINITY)).toBe(Number.POSITIVE_INFINITY);
+ expect(rt(Number.NEGATIVE_INFINITY)).toBe(Number.NEGATIVE_INFINITY);
+ expect(rt(9007199254740993n)).toBe(9007199254740993n);
+ });
+
+ test("strings that look like ref tokens survive escaping", () => {
+ expect(rt("@1")).toBe("@1");
+ expect(rt("@42 hello")).toBe("@42 hello");
+ expect(rt("@@escaped")).toBe("@@escaped");
+ const back = rt({ a: "@1", b: new Date(0) });
+ expect(back.a).toBe("@1");
+ expect(back.b).toBeInstanceOf(Date);
+ });
+
+ test("Date (valid and invalid), RegExp, URL", () => {
+ expect(rt(new Date(0))).toEqual(new Date(0));
+ expect(Number.isNaN(rt(new Date(Number.NaN)).getTime())).toBe(true);
+ expect(rt(/foo\d+/giu)).toEqual(/foo\d+/giu);
+ const u = rt(new URL("https://a.example/p?q=1#h"));
+ expect(u).toBeInstanceOf(URL);
+ expect(u.href).toBe("https://a.example/p?q=1#h");
+ });
+
+ test("Map and Set, incl. object keys", () => {
+ expect(rt(new Map([["k", 1]]))).toEqual(new Map([["k", 1]]));
+ expect(rt(new Set([1, 2, 3]))).toEqual(new Set([1, 2, 3]));
+ const m = rt(new Map([[{ id: 1 }, new Date(0)]]));
+ expect([...m.keys()][0]).toEqual({ id: 1 });
+ expect([...m.values()][0]).toEqual(new Date(0));
+ });
+
+ test("typed arrays / ArrayBuffer / DataView are byte-exact", () => {
+ expect(rt(new Uint8Array([1, 2, 3]))).toEqual(new Uint8Array([1, 2, 3]));
+ const f = rt(new Float64Array([Number.NaN, Number.POSITIVE_INFINITY, -0]));
+ expect(f).toBeInstanceOf(Float64Array);
+ expect(Number.isNaN(f[0])).toBe(true);
+ expect(Object.is(f[2], -0)).toBe(true);
+ expect(rt(new BigInt64Array([9007199254740993n]))[0]).toBe(9007199254740993n);
+ // a view into the middle of a larger buffer encodes only its own bytes
+ const view = new Uint16Array(new Uint8Array([0, 0, 1, 0, 2, 0, 0, 0]).buffer, 2, 2);
+ expect(rt(view)).toEqual(new Uint16Array([1, 2]));
+ expect(new Uint8Array(rt(new Uint8Array([9, 8, 7]).buffer))).toEqual(new Uint8Array([9, 8, 7]));
+ const dv = rt(new DataView(new Uint8Array([1, 2, 3, 4]).buffer));
+ expect(dv).toBeInstanceOf(DataView);
+ expect(dv.getUint8(3)).toBe(4);
+ });
+
+ test("Error and standard subclasses keep name, message and own props", () => {
+ const e = new TypeError("boom") as TypeError & { code?: string };
+ e.code = "E_BOOM";
+ const back = rt(e);
+ expect(back).toBeInstanceOf(TypeError);
+ expect(back.message).toBe("boom");
+ expect(back.code).toBe("E_BOOM");
+ const custom = new Error("x");
+ custom.name = "MyError";
+ expect(rt(custom).name).toBe("MyError");
+ });
+
+ test("boxed primitives", () => {
+ const n = rt(new Number(42));
+ expect(n).toBeInstanceOf(Number);
+ expect(n.valueOf()).toBe(42);
+ expect(rt(new Number(Number.NaN)).valueOf()).toBeNaN();
+ expect(rt(new String("hi")).valueOf()).toBe("hi");
+ expect(rt(new Boolean(true)).valueOf()).toBe(true);
+ });
+
+ test("Temporal", () => {
+ const d = rt(Temporal.PlainDate.from("2021-06-15"));
+ expect(d.toString()).toBe("2021-06-15");
+ const z = rt(Temporal.ZonedDateTime.from("2021-06-15T08:30[America/New_York]"));
+ expect(z.toString()).toBe("2021-06-15T08:30:00-04:00[America/New_York]");
+ expect(rt(Temporal.Duration.from({ hours: 2 })).toString()).toBe("PT2H");
+ });
+
+ test("nested mix of special types", () => {
+ const value = {
+ user: {
+ joined: new Date("2020-01-01T00:00:00Z"),
+ roles: new Set(["admin"]),
+ prefs: new Map([["theme", "dark"]]),
+ scores: new Float64Array([1.5, 2.5]),
+ big: 123n,
+ seen: [new Date(0), undefined],
+ },
+ };
+ expect(rt(value)).toEqual(value);
+ });
+
+ test("user keys named _t / _v are ordinary data", () => {
+ expect(rt({ _t: "Date", _v: "not a date" })).toEqual({ _t: "Date", _v: "not a date" });
+ });
+
+ test("circular references and shared identity", () => {
+ const self: Record = { name: "a" };
+ self.self = self;
+ const back = rt(self);
+ expect(back.name).toBe("a");
+ expect(back.self).toBe(back);
+
+ const a: Record = { id: "a" };
+ const b: Record = { id: "b", peer: a };
+ a.peer = b;
+ const ra = rt(a);
+ expect((ra.peer as Record).peer).toBe(ra);
+
+ const arr: unknown[] = [1];
+ arr.push(arr);
+ const rarr = rt(arr);
+ expect(rarr[1]).toBe(rarr);
+
+ const shared = { id: 7 };
+ const pair = rt({ a: shared, b: shared, list: [shared] });
+ expect(pair.a).toBe(pair.b);
+ expect(pair.list[0]).toBe(pair.a);
+
+ // a plain object shared through a Map is still one object
+ const viaMap = rt({ o: shared, m: new Map([["k", shared]]) });
+ expect(viaMap.m.get("k")).toBe(viaMap.o);
+ });
+
+ test("throws on symbols, functions, class instances", () => {
+ expect(() => stringify(Symbol("s"))).toThrow();
+ expect(() => stringify(() => 1)).toThrow();
+ class Widget {}
+ expect(() => stringify(new Widget())).toThrow();
+ });
+});
+
+describe("wire format (stays compatible with obj-diff 0.18)", () => {
+ test("single-use containers stay inline and no $refs is written", () => {
+ expect(stringify({ user: { name: "a", tags: ["x"] } })).toBe(
+ '{"v":{"user":{"name":"a","tags":["x"]}}}',
+ );
+ });
+
+ test("special values become tokens into the $refs table", () => {
+ expect(stringify({ d: new Date(0), s: new Set([1]) })).toBe(
+ '{"v":{"d":"@1","s":"@2"},"$refs":{"1":{"_t":"Date","_v":"1970-01-01T00:00:00.000Z"},"2":{"_t":"Set","_v":[1]}}}',
+ );
+ });
+
+ test("shared containers are hoisted as obj/arr entries", () => {
+ const shared = { id: 7 };
+ expect(stringify({ a: shared, b: shared })).toBe(
+ '{"v":{"a":"@1","b":"@1"},"$refs":{"1":{"_t":"obj","_v":{"id":7}}}}',
+ );
+ });
+
+ test("parses output written by obj-diff's stringify", () => {
+ const v = parse<{ m: Map }>(
+ '{"v":{"m":"@1"},"$refs":{"1":{"_t":"Map","_v":[["k","@2"]]},"2":{"_t":"bigint","_v":"5"}}}',
+ );
+ expect(v.m.get("k")).toBe(5n);
+ });
+});
diff --git a/__tests__/graphs.test.ts b/__tests__/graphs.test.ts
new file mode 100644
index 0000000..f3f1565
--- /dev/null
+++ b/__tests__/graphs.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, test } from "runtime:test";
+import { decode, encode, parse, stringify } from "./lib";
+
+const rt = (v: T): T => parse(stringify(v));
+
+describe("cycles and shared identity through Map, Set and Error", () => {
+ test("a Map that contains itself, as a value and as a key", () => {
+ const m = new Map();
+ m.set("self", m);
+ m.set(m, "key");
+ const back = rt(m);
+ expect(back).toBeInstanceOf(Map);
+ expect(back.get("self")).toBe(back);
+ expect(back.get(back)).toBe("key");
+ });
+
+ test("a Set that contains itself", () => {
+ const s = new Set([1]);
+ s.add(s);
+ const back = rt(s);
+ expect(back.has(back)).toBe(true);
+ expect(back.has(1)).toBe(true);
+ });
+
+ test("an Error whose own property points back at it", () => {
+ const e = new RangeError("loop") as RangeError & { self?: unknown; code?: string };
+ e.self = e;
+ e.code = "E_LOOP";
+ const back = rt(e);
+ expect(back).toBeInstanceOf(RangeError);
+ expect(back.self).toBe(back);
+ expect(back.code).toBe("E_LOOP");
+ });
+
+ test("cycles that cross plain objects and special containers", () => {
+ const node: Record = { name: "root" };
+ const children = new Map([["parent", node]]);
+ node.children = children;
+ node.seen = new Set([node, children]);
+ const back = rt(node);
+ const kids = back.children as Map;
+ expect(kids.get("parent")).toBe(back);
+ const seen = back.seen as Set;
+ expect(seen.has(back)).toBe(true);
+ expect(seen.has(kids)).toBe(true);
+ });
+
+ test("a Map, Set, Date or typed array used twice stays one object", () => {
+ const m = new Map([["k", 1]]);
+ const s = new Set([1]);
+ const d = new Date(0);
+ const t = new Uint8Array([1, 2]);
+ const back = rt({ a: [m, s, d, t], b: [m, s, d, t] });
+ for (let i = 0; i < 4; i++) expect(back.a[i]).toBe(back.b[i]);
+ expect(back.a[2]).toEqual(new Date(0));
+ });
+
+ test("identity is shared across encode roots", () => {
+ const m = new Map();
+ m.set("self", m);
+ const { values, refs } = encode([["path", m], m]);
+ const [path, value] = decode(JSON.parse(JSON.stringify(values)), refs) as [unknown[], unknown];
+ expect(path[1]).toBe(value);
+ expect((value as Map).get("self")).toBe(value);
+ });
+
+ test("a shared special value is written once and referenced by token", () => {
+ const d = new Date(0);
+ expect(stringify([d, d])).toBe(
+ '{"v":["@1","@1"],"$refs":{"1":{"_t":"Date","_v":"1970-01-01T00:00:00.000Z"}}}',
+ );
+ const m = new Map();
+ m.set("self", m);
+ expect(stringify(m)).toBe('{"v":"@1","$refs":{"1":{"_t":"Map","_v":[["self","@1"]]}}}');
+ });
+
+ test("maxDepth still bounds a cycle through a Map", () => {
+ const m = new Map([["inner", new Map([["x", [1]]])]]);
+ m.set("self", m);
+ expect(() => parse(stringify(m), { maxDepth: 2 })).toThrow("maxDepth");
+ expect(rt(m).get("self")).toBeInstanceOf(Map);
+ });
+});
+
+describe("sparse arrays", () => {
+ test("holes stay holes, distinct from undefined and null", () => {
+ // biome-ignore lint/suspicious/noSparseArray: the case under test
+ const back = rt([1, , undefined, null, , 6]);
+ expect(back.length).toBe(6);
+ expect(Object.keys(back)).toEqual(["0", "2", "3", "5"]);
+ expect(1 in back).toBe(false);
+ expect(2 in back).toBe(true);
+ expect(back[2]).toBe(undefined);
+ expect(back[3]).toBe(null);
+ });
+
+ test("leading, trailing and all-hole arrays keep their length", () => {
+ // biome-ignore lint/suspicious/noSparseArray: the case under test
+ const trailing = rt([1, ,]);
+ expect(trailing.length).toBe(2);
+ expect(1 in trailing).toBe(false);
+ const empty = rt(new Array(3));
+ expect(empty.length).toBe(3);
+ expect(Object.keys(empty)).toEqual([]);
+ });
+
+ test("every hole shares one $refs entry", () => {
+ // biome-ignore lint/suspicious/noSparseArray: the case under test
+ expect(stringify([, 1, ,])).toBe('{"v":["@1",1,"@1"],"$refs":{"1":{"_t":"hole"}}}');
+ });
+
+ test("holes inside hoisted, nested and Set-held arrays", () => {
+ // biome-ignore lint/suspicious/noSparseArray: the case under test
+ const shared = [1, , 3];
+ // biome-ignore lint/suspicious/noSparseArray: the case under test
+ const back = rt({ a: shared, b: shared, deep: [[, 2]], s: new Set([[, "x"]]) });
+ expect(back.a).toBe(back.b);
+ expect(1 in back.a).toBe(false);
+ expect(0 in back.deep[0]).toBe(false);
+ expect(0 in [...back.s][0]).toBe(false);
+ });
+
+ test("dense arrays are written exactly as before", () => {
+ expect(stringify([1, undefined, 3])).toBe('{"v":[1,"@1",3],"$refs":{"1":{"_t":"undef"}}}');
+ });
+});
diff --git a/__tests__/lib.ts b/__tests__/lib.ts
new file mode 100644
index 0000000..65ef6af
--- /dev/null
+++ b/__tests__/lib.ts
@@ -0,0 +1,9 @@
+// Tests run against src by default; TEST_DIST=true runs them against the build.
+import { env } from "runtime:process";
+import type * as Lib from "../src/index";
+
+const lib: typeof Lib = env.TEST_DIST
+ ? await import("../dist/index.js")
+ : await import("../src/index");
+
+export const { stringify, parse, encode, decode, SerializeError } = lib;
diff --git a/__tests__/safety.test.ts b/__tests__/safety.test.ts
new file mode 100644
index 0000000..198203d
--- /dev/null
+++ b/__tests__/safety.test.ts
@@ -0,0 +1,200 @@
+import { describe, expect, test } from "runtime:test";
+import { decode, encode, parse, SerializeError, stringify } from "./lib";
+
+/** Runs fn and returns the SerializeError it throws (fails the test otherwise). */
+function codeOf(fn: () => unknown): string {
+ try {
+ fn();
+ } catch (err) {
+ expect(err).toBeInstanceOf(SerializeError);
+ return (err as InstanceType).code;
+ }
+ throw new Error("expected a SerializeError, but nothing was thrown");
+}
+
+describe("__proto__ keys are own properties, never prototypes", () => {
+ test("parse defines __proto__ as an own key in plain objects", () => {
+ const v = parse>('{"v":{"__proto__":{"polluted":true},"a":1}}');
+ expect(Object.getPrototypeOf(v)).toBe(Object.prototype);
+ expect(Object.hasOwn(v, "__proto__")).toBe(true);
+ expect(Object.keys(v)).toEqual(["__proto__", "a"]);
+ expect((v as { polluted?: unknown }).polluted).toBeUndefined();
+ expect(({} as { polluted?: unknown }).polluted).toBeUndefined();
+ });
+
+ test("hoisted objects and Error own props are safe too", () => {
+ const hoisted = parse<{ a: object; b: object }>(
+ '{"v":{"a":"@1","b":"@1"},"$refs":{"1":{"_t":"obj","_v":{"__proto__":{"x":1}}}}}',
+ );
+ expect(Object.getPrototypeOf(hoisted.a)).toBe(Object.prototype);
+ expect(Object.hasOwn(hoisted.a, "__proto__")).toBe(true);
+
+ const err = parse(
+ '{"v":"@1","$refs":{"1":{"_t":"Error","_v":{"name":"Error","message":"m","own":{"__proto__":{"x":1}}}}}}',
+ );
+ expect(Object.getPrototypeOf(err)).toBe(Error.prototype);
+ expect(Object.hasOwn(err, "__proto__")).toBe(true);
+ });
+
+ test("an own __proto__ key round-trips through stringify", () => {
+ const src = JSON.parse('{"__proto__":{"deep":1},"b":2}');
+ const back = parse>(stringify(src));
+ expect(Object.hasOwn(back, "__proto__")).toBe(true);
+ expect(Object.getOwnPropertyDescriptor(back, "__proto__")?.value).toEqual({ deep: 1 });
+ expect(Object.getPrototypeOf(back)).toBe(Object.prototype);
+ });
+
+ test("type tags cannot reach Object.prototype members", () => {
+ expect(codeOf(() => parse('{"v":"@1","$refs":{"1":{"_t":"constructor","_v":"x"}}}'))).toBe(
+ "UNKNOWN_TYPE",
+ );
+ expect(codeOf(() => parse('{"v":"@1","$refs":{"1":{"_t":"toString","_v":"x"}}}'))).toBe(
+ "UNKNOWN_TYPE",
+ );
+ // an Error name that is an Object.prototype key falls back to plain Error
+ const e = parse(
+ '{"v":"@1","$refs":{"1":{"_t":"Error","_v":{"name":"constructor","message":"m","own":{}}}}}',
+ );
+ expect(e).toBeInstanceOf(Error);
+ expect(e.name).toBe("constructor");
+ expect(
+ codeOf(() => parse('{"v":"@1","$refs":{"1":{"_t":"Temporal.__proto__","_v":"x"}}}')),
+ ).toBe("UNKNOWN_TYPE");
+ });
+});
+
+describe("clear errors for malformed input", () => {
+ test("missing {v, $refs} envelope", () => {
+ for (const bad of ["{}", '{"$refs":{}}', "[]", "null", "42", '"str"', '{"value":1}']) {
+ expect(codeOf(() => parse(bad))).toBe("INVALID_ENVELOPE");
+ }
+ expect(() => parse("{}")).toThrow(
+ 'envelope written by stringify(), got an object without a "v" key',
+ );
+ // an explicit null value is a valid envelope
+ expect(parse('{"v":null}')).toBe(null);
+ });
+
+ test("non-string input and invalid JSON", () => {
+ expect(codeOf(() => parse(undefined as unknown as string))).toBe("INVALID_INPUT");
+ expect(codeOf(() => parse({ v: 1 } as unknown as string))).toBe("INVALID_INPUT");
+ expect(codeOf(() => parse("{nope"))).toBe("INVALID_JSON");
+ });
+
+ test("bad $refs table and entries", () => {
+ expect(codeOf(() => parse('{"v":1,"$refs":[]}'))).toBe("INVALID_ENVELOPE");
+ expect(codeOf(() => parse('{"v":1,"$refs":"x"}'))).toBe("INVALID_ENVELOPE");
+ expect(codeOf(() => parse('{"v":"@9"}'))).toBe("MISSING_REF");
+ expect(codeOf(() => parse('{"v":"@1","$refs":{"1":5}}'))).toBe("INVALID_PAYLOAD");
+ expect(codeOf(() => parse('{"v":"@1","$refs":{"1":{"_v":1}}}'))).toBe("INVALID_PAYLOAD");
+ expect(codeOf(() => parse('{"v":"@1","$refs":{"1":{"_t":"Bogus"}}}'))).toBe("UNKNOWN_TYPE");
+ });
+
+ test("bad payloads throw INVALID_PAYLOAD instead of building garbage", () => {
+ const one = (t: string, v: unknown) =>
+ codeOf(() => parse(JSON.stringify({ v: "@1", $refs: { 1: { _t: t, _v: v } } })));
+ expect(one("bigint", "abc")).toBe("INVALID_PAYLOAD");
+ expect(one("bigint", 5)).toBe("INVALID_PAYLOAD");
+ expect(one("num", "7")).toBe("INVALID_PAYLOAD");
+ expect(one("RegExp", "abc")).toBe("INVALID_PAYLOAD");
+ expect(one("RegExp", ["(", ""])).toBe("INVALID_PAYLOAD");
+ expect(one("URL", "not a url")).toBe("INVALID_PAYLOAD");
+ expect(one("Map", [1, 2])).toBe("INVALID_PAYLOAD");
+ expect(one("Set", {})).toBe("INVALID_PAYLOAD");
+ expect(one("Uint16Array", "AQID")).toBe("INVALID_PAYLOAD"); // 3 bytes: misaligned
+ expect(one("Uint8Array", "!!!")).toBe("INVALID_PAYLOAD");
+ expect(one("Error", { name: "Error" })).toBe("INVALID_PAYLOAD");
+ expect(one("Number", "x")).toBe("INVALID_PAYLOAD");
+ expect(one("obj", [])).toBe("INVALID_PAYLOAD");
+ expect(one("arr", {})).toBe("INVALID_PAYLOAD");
+ });
+
+ test("a leaf entry that refers to itself throws CIRCULAR_REF, not a stack overflow", () => {
+ expect(codeOf(() => parse('{"v":"@1","$refs":{"1":{"_t":"Number","_v":"@1"}}}'))).toBe(
+ "CIRCULAR_REF",
+ );
+ });
+
+ test("a hole token outside an array element is rejected", () => {
+ const hole = '"$refs":{"1":{"_t":"hole"}}';
+ expect(codeOf(() => parse(`{"v":"@1",${hole}}`))).toBe("INVALID_PAYLOAD");
+ expect(codeOf(() => parse(`{"v":{"a":"@1"},${hole}}`))).toBe("INVALID_PAYLOAD");
+ expect(
+ codeOf(() => parse(`{"v":"@2",${hole.slice(0, -1)},"2":{"_t":"Set","_v":["@1"]}}}`)),
+ ).toBe("INVALID_PAYLOAD");
+ });
+
+ test("stringify errors are SerializeErrors with codes", () => {
+ expect(codeOf(() => stringify(Symbol("s")))).toBe("UNSUPPORTED_VALUE");
+ class Widget {}
+ expect(codeOf(() => stringify(new Widget()))).toBe("UNSUPPORTED_VALUE");
+ });
+});
+
+describe("optional limits", () => {
+ test("maxLength rejects oversized input before parsing", () => {
+ const text = stringify({ list: [1, 2, 3] });
+ expect(parse(text, { maxLength: text.length })).toEqual({ list: [1, 2, 3] });
+ expect(codeOf(() => parse(text, { maxLength: text.length - 1 }))).toBe("INPUT_TOO_LARGE");
+ // checked before JSON.parse, so even invalid JSON reports the size first
+ expect(codeOf(() => parse("x".repeat(100), { maxLength: 10 }))).toBe("INPUT_TOO_LARGE");
+ });
+
+ test("maxDepth counts container nesting on parse", () => {
+ const text = stringify({ a: [{ b: 1 }] }); // object > array > object = 3
+ expect(parse(text, { maxDepth: 3 })).toEqual({ a: [{ b: 1 }] });
+ expect(codeOf(() => parse(text, { maxDepth: 2 }))).toBe("MAX_DEPTH");
+ expect(parse('{"v":5}', { maxDepth: 0 })).toBe(5);
+ expect(codeOf(() => parse('{"v":[]}', { maxDepth: 0 }))).toBe("MAX_DEPTH");
+ });
+
+ test("maxDepth also counts Map, Set, Error and hoisted containers", () => {
+ const viaMap = stringify(new Map([["k", new Set([[1]])]])); // Map > Set > array = 3
+ expect(parse(viaMap, { maxDepth: 3 })).toBeInstanceOf(Map);
+ expect(codeOf(() => parse(viaMap, { maxDepth: 2 }))).toBe("MAX_DEPTH");
+
+ const shared = { x: { y: 1 } };
+ const hoisted = stringify([shared, shared]); // array > obj(hoisted) > obj = 3
+ expect(codeOf(() => parse(hoisted, { maxDepth: 2 }))).toBe("MAX_DEPTH");
+ expect(parse(hoisted, { maxDepth: 3 })[0]).toEqual(shared);
+ });
+
+ test("maxDepth stops pathologically deep input", () => {
+ const deep = `{"v":${"[".repeat(5000)}${"]".repeat(5000)}}`;
+ expect(codeOf(() => parse(deep, { maxDepth: 100 }))).toBe("MAX_DEPTH");
+ });
+
+ test("stringify accepts maxDepth too", () => {
+ let deep: unknown = 1;
+ for (let i = 0; i < 50; i++) deep = [deep];
+ expect(codeOf(() => stringify(deep, { maxDepth: 10 }))).toBe("MAX_DEPTH");
+ expect(parse(stringify(deep, { maxDepth: 50 }))).toEqual(deep);
+ });
+
+ test("invalid limit options are rejected", () => {
+ expect(codeOf(() => parse('{"v":1}', { maxDepth: -1 }))).toBe("INVALID_OPTION");
+ expect(codeOf(() => parse('{"v":1}', { maxLength: 1.5 }))).toBe("INVALID_OPTION");
+ expect(codeOf(() => stringify(1, { maxDepth: Number.NaN }))).toBe("INVALID_OPTION");
+ expect(parse('{"v":1}', { maxDepth: Number.POSITIVE_INFINITY })).toBe(1);
+ });
+});
+
+describe("encode / decode (low-level, for embedding)", () => {
+ test("roots share one $refs table, so identity crosses roots", () => {
+ const key = { id: 1 };
+ const { values, refs } = encode([["users", key], key]);
+ expect(refs).toBeDefined();
+ const [path, value] = decode(JSON.parse(JSON.stringify(values)), refs) as [unknown[], unknown];
+ expect(path[1]).toBe(value);
+ });
+
+ test("refs is undefined when nothing needs a ref", () => {
+ expect(encode([{ a: 1 }, "b"])).toEqual({ values: [{ a: 1 }, "b"], refs: undefined });
+ expect(decode([{ a: 1 }, "@@x"], undefined)).toEqual([{ a: 1 }, "@x"]);
+ });
+
+ test("decode validates refs and honours maxDepth", () => {
+ expect(codeOf(() => decode(["@1"], []))).toBe("INVALID_ENVELOPE");
+ expect(codeOf(() => decode([[[1]]], undefined, { maxDepth: 1 }))).toBe("MAX_DEPTH");
+ });
+});
diff --git a/benchmarks/cases.ts b/benchmarks/cases.ts
new file mode 100644
index 0000000..0e4661e
--- /dev/null
+++ b/benchmarks/cases.ts
@@ -0,0 +1,209 @@
+// Inputs for the comparison: fidelity cases, size cases and speed payloads.
+
+// ---- cycle-safe, type-aware equality ---------------------------------------
+
+function sameBytes(x: Uint8Array, y: Uint8Array): boolean {
+ if (x.length !== y.length) return false;
+ for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false;
+ return true;
+}
+
+const tag = (x: unknown): string => Object.prototype.toString.call(x);
+
+/** True when `b` has the same types, values, keys and graph shape as `a`. */
+export function sameValue(a: unknown, b: unknown, seen = new Map