# json-freeze

RFC 8785 Canonical JSON for JavaScript.

```js
JSON.stringify({ name: 'Alice', age: 30 })
// '{"name":"Alice","age":30}'

JSON.stringify({ age: 30, name: 'Alice' })
// '{"age":30,"name":"Alice"}'

// Same data. Different bytes. Any hash, signature, or content
// address built on top of JSON.stringify silently breaks.
```

```js
import { canonicalize } from 'json-freeze'

canonicalize({ name: 'Alice', age: 30 })
// '{"age":30,"name":"Alice"}'

canonicalize({ age: 30, name: 'Alice' })
// '{"age":30,"name":"Alice"}'

// Same data. Same bytes. That's what RFC 8785 guarantees.
```

`json-freeze` implements [RFC 8785 (JSON Canonicalization Scheme)](https://www.rfc-editor.org/rfc/rfc8785), the IETF standard for canonical JSON. It's written in pure TypeScript, has no runtime dependencies, and runs unchanged in Node, Bun, Deno and the browsers.

## Features

- Full RFC 8785 compliance
- String or `Uint8Array` output for hashing and signing
- Typed errors with `code` and `path`
- `JSON.stringify`-compatible `replacer` for `BigInt`, `Date`, and custom types
- CLI for canonicalizing JSON from the shell

## Installation

```sh
npm install json-freeze
# or
pnpm add json-freeze
# or
yarn add json-freeze
```

## Quick start

### Hash a payload in Node

A canonical hash is the cheapest content identifier you can compute. Two processes on different machines that hold the same logical payload produce the same hash, no matter how each one serialized it.

```ts
import { canonicalizeBytes } from 'json-freeze'
import { createHash } from 'node:crypto'

function contentId(value: unknown): string {
  return createHash('sha256').update(canonicalizeBytes(value)).digest('hex')
}

const orderId = contentId({
  userId: 'acct_42',
  amount: 1500,
  currency: 'USD',
  lineItems: [
    { sku: 'book', quantity: 1 },
    { sku: 'mug', quantity: 2 },
  ],
})
```

### Sign and verify in the browser

The signer and the verifier typically run in different processes, often on different machines. Each side calls `canonicalizeBytes` independently, so the input to the signing and verifying algorithms is byte identical no matter how each side received the payload.

```ts
import { canonicalizeBytes } from 'json-freeze'

// Runs on the producer, which holds the private key.
async function sign(payload: unknown, privateKey: CryptoKey) {
  return crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, canonicalizeBytes(payload))
}

// Runs on the verifier, which holds the matching public key.
async function verify(payload: unknown, signature: ArrayBuffer, publicKey: CryptoKey) {
  return crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, publicKey, signature, canonicalizeBytes(payload))
}
```

### Canonicalize from the shell

The `json-freeze` CLI reads JSON from `stdin` or a file and prints the canonical form to `stdout`.

```sh
echo '{"b":2,"a":1}' | json-freeze
# {"a":1,"b":2}

json-freeze payload.json
# {"amount":1500,"currency":"USD"}
```

## API reference

### `canonicalize(value, options?): string`

Returns the RFC 8785 canonical string for `value`. Throws `CanonicalizeError` on any value that cannot be canonicalized, such as `BigInt`, `NaN`, or a circular reference.

### `canonicalizeBytes(value, options?): Uint8Array`

Returns the UTF-8 byte sequence of the canonical string. Equivalent to `new TextEncoder().encode(canonicalize(value, options))` but avoids the manual encoding step at the call site. Same throwing contract as `canonicalize`.

### `Options`

```ts
type Options = {
  replacer?: (this: unknown, key: string, value: unknown) => unknown
}
```

The `replacer` matches the second argument to `JSON.stringify`. It runs after any `toJSON` method on the value. Return a transformed value to substitute it, or return `undefined` to drop an object key or emit `null` in an array position.

```ts
canonicalize(
  { id: 9007199254740993n },
  {
    replacer(_key, value) {
      if (typeof value === 'bigint') {
        return value.toString()
      }
      return value
    },
  }
)
// {"id":"9007199254740993"}
```

### `CanonicalizeError`

```ts
class CanonicalizeError extends Error {
  readonly name: 'CanonicalizeError'
  readonly code: CanonicalizeErrorCode
  readonly path: readonly string[]
}
```

`path` is the route from the root to the failing value. Each segment is a property name or a stringified array index. An empty array means the root itself.

| `code`               | Triggered by                                                                 |
| -------------------- | ---------------------------------------------------------------------------- |
| `UNSUPPORTED_TYPE`   | `BigInt`, `Symbol`, `Function`, or a root value that resolves to `undefined` |
| `NON_FINITE_NUMBER`  | `NaN`, `Infinity`, `-Infinity`                                               |
| `CIRCULAR_REFERENCE` | An object or array that references one of its ancestors                      |
| `DUPLICATE_KEY`      | A replacer produced two entries with the same key                            |

### `JsonValue`

```ts
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
```

Recursive union covering every value the library can serialize without a replacer. Use it as a parameter type when you want the compiler to reject non spec inputs at the call site.

### CLI

```
Usage: json-freeze [file]

Reads JSON from stdin or the given file, writes the RFC 8785 canonical form
to stdout, and exits. A trailing newline is appended for shell friendliness.

Options:
  -h, --help    show this help text

Exit codes:
  0  success
  1  input, file, or JSON parse error
  2  canonicalization error (unsupported type, non finite number, cycle)
```

## License

`json-freeze` is open-source software licensed under the [MIT License](./LICENSE).
