json-freeze
v1.0.0
Published
RFC 8785 Canonical JSON for JavaScript.
Downloads
42,716
Maintainers
Readme
json-freeze
RFC 8785 Canonical JSON for JavaScript.
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.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), 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
Uint8Arrayoutput for hashing and signing - Typed errors with
codeandpath JSON.stringify-compatiblereplacerforBigInt,Date, and custom types- CLI for canonicalizing JSON from the shell
Installation
npm install json-freeze
# or
pnpm add json-freeze
# or
yarn add json-freezeQuick 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.
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.
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.
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
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.
canonicalize(
{ id: 9007199254740993n },
{
replacer(_key, value) {
if (typeof value === 'bigint') {
return value.toString()
}
return value
},
}
)
// {"id":"9007199254740993"}CanonicalizeError
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
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.
