A TypeScript/Node.js implementation of RON (Readable Object Notation).
RON keeps JSON's value model but drops avoidable syntax: top-level object braces can be
elided, strings can be bare, commas are optional separators, and quoted strings use repeated
'/" delimiters with no backslash escapes. It converts losslessly to and from JSON and is
cheaper for humans and LLMs to read and write.
This library is a port of the reference Go implementation (ron-go) and passes the upstream conformance corpus, the RFC 8785 (JCS) canonical-JSON corpus, and the typed-vocabulary fixtures.
- Node ≥ 22
- ESM-only (
"type": "module")
pnpm add ron-nodeimport { toJSON, fromJSON } from "ron-node";
// JSON -> RON (pretty by default, canonical key order)
fromJSON('{"name":"Ada","active":true}');
// active true
// name Ada
// RON -> JSON (compact by default, canonical key order)
toJSON("name Ada\nactive true"); // {"active":true,"name":"Ada"}toJSON(ron: string, opts?: Options): string // RON -> JSON (compact by default)
toJSONBytes(ron: string, opts?: Options): Uint8Array // RON -> JSON bytes (compact, streaming)
toJSONStream(ron: string, opts?: Options): ReadableStream<Uint8Array> // streaming variant
fromJSON(json: string, opts?: Options): string // JSON -> RON (pretty by default)
fromJSONBytes(json: string, opts?: Options): Uint8Array // JSON -> RON bytes (compact, streaming)
fromJSONStream(json: string, opts?: Options): ReadableStream<Uint8Array> // streaming variant
parseJSON(json: string): Value // parsed value tree (re-exported)
canonicalJSON(src: string): string // RFC 8785 (JCS) canonical JSON
appendRFC8785Number(value: number): string // RFC 8785 number formatting
canonicalHash(json: string): string // 64 lowercase hex SHA-256 of canonical RON
defineVocabulary(config: VocabularyConfig): CustomVocabularySpec // typed custom-vocab builder
pathMatcher(...rules: PathMatcherRule[]): Options // path-based value replacementVocabulary helpers exported for custom-vocab authors: asString, asArray, asObject,
asRonNumber, numberAsFloat64, numberAsInt64, numberAsUint64, fail, tagged,
ronNumber, formatFloat64, floatArray, intArray.
Options is { isPretty?: boolean; isCanonical?: boolean } plus vocabulary options (see below).
Error classes:
ParseError— RON parse failure, carries a.posbyte offset.JSONParseError— JSON parse failure, carries a.posbyte offset.RFC8785Error— RFC 8785 canonicalization failure.VocabError— vocabulary validation failure.
fromJSON accepts a value mapper that rewrites JSON values before rendering, e.g. to emit
typed RON forms. The mapper receives the path (object keys as strings, array indices as
numbers; root is []) and the value, and returns a replacement Value or undefined to
leave it untouched. This mirrors Go's MapJSONValues / Tagged example:
import { fromJSON, MapJSONValues, Tagged } from "ron-node";
const json = JSON.stringify({
tx: "tx-48830",
committed: "2026-06-13T00:00:00Z",
});
const ron = fromJSON(
json,
MapJSONValues((path, value) => {
if (path.length !== 1 || path[0].isIndex) return undefined;
switch (path[0].key) {
case "tx":
return Tagged("", value); // -> tx {# tx-48830}
case "committed":
return Tagged("time", value); // -> committed {#time 2026-06-13T00:00:00Z}
default:
return undefined;
}
}),
);Each PathSegment is { key: string; index: number; isIndex: boolean } — key holds
the object key (empty for array elements), index holds the array index, and isIndex
distinguishes the two. Root is [].
Output:
tx {# tx-48830}
committed {#time 2026-06-13T00:00:00Z}Tagged(tag, value) builds a single-key RonObject whose key is the normalized tag
("" -> "#", "time" -> "#time"); normalizeCustomTag(tag) exposes that normalization.
For the common case of replacing values at known paths, pathMatcher removes the
PathSegment boilerplate — it takes friendly Array<string | number> paths and returns
an Options you pass directly to fromJSON:
import { fromJSON, pathMatcher, Tagged } from "ron-node";
const ron = fromJSON(
json,
pathMatcher(
{ path: ["tx"], replaceWith: (v) => Tagged("", v) }, // -> tx {# tx-48830}
{ path: ["committed"], replaceWith: (v) => Tagged("time", v) }, // -> committed {#time ...}
{ path: ["grid", 2], replaceWith: ronNumber("99") }, // replace $.grid[2]
),
);replaceWith may be a fixed Value or (value) => Value. MapJSONValues remains
available for custom logic that inspects the whole path.
fromJSONBytes / fromJSONStream (JSON→RON) and toJSONBytes / toJSONStream
(RON→JSON) use streaming transcoders that lex one format and emit the other's
compact bytes directly — never materialising the RonObject/RonArray/RonNumber
tree. Both are faster than the tree-based fromJSON / toJSON for compact output:
import { fromJSONBytes, fromJSONStream, toJSONBytes, toJSONStream } from "ron-node";
// Sync — returns bytes
const ronBytes = fromJSONBytes(json, { isPretty: false });
const jsonBytes = toJSONBytes(ron, { isPretty: false });
// Use new TextDecoder().decode(bytes) if you need a string.
// Async — pipe to a WritableStream (e.g. HTTP response body)
const ronStream = fromJSONStream(json, { isPretty: false });
const jsonStream = toJSONStream(ron, { isPretty: false });
await ronStream.pipeTo(writable);Both fall back to the tree-based path automatically for:
- pretty mode (
isPretty: true) - vocabulary processing (mapper set, or input contains
#) - duplicate keys (the tree deduplicates; the transcoder doesn't)
- RON→JSON only: top-level scalars (the tree dispatches to
parseValue)
The output is byte-identical to new TextEncoder().encode(fromJSON(json, opts)) /
new TextEncoder().encode(toJSON(ron, opts)). canonicalHash uses the streaming
path internally, giving it the same speedup.
A typed value is a single-key object whose key starts with #, e.g. {"#utc": "..."}, which
RON renders compactly as {#utc ...}. This rendering is always on. Optionally, fromJSON can
validate typed payloads against the official vocabularies.
The core vocabulary is enabled by default; the rest are opt-in:
import {
EnableVocabularies,
fromJSON,
validateVocabularyProfile,
VocabularyColorV1,
VocabularyGeoV1,
VocabularyMathV1,
VocabularyNetworkV1,
VocabularySetV1,
VocabularySpatialV1,
VocabularyTimeV1,
} from "ron-node";
// Core is validated by default: a malformed payload throws.
fromJSON('{"id":{"#uid":"not-a-uuid"}}'); // throws VocabError
// Enable additional vocabularies explicitly.
fromJSON(json, EnableVocabularies(VocabularyTimeV1, VocabularyNetworkV1));
// Validate without rendering.
validateVocabularyProfile(profile, EnableVocabularies(VocabularySpatialV1));Custom, namespaced vocabularies are registered with UseCustomVocabulary. A
CustomVocabularySpec declares a uri, the tags it owns, and an optional
parse function that validates or transforms each payload. The bundled
invoiceVocabulary is a working template:
import { fromJSON, toJSON, UseCustomVocabulary, invoiceVocabulary } from "ron-node";
const json = JSON.stringify({
amount: { "#com.example/money": ["USD", "12.50"] },
rating: { "#com.example/rating": 5 },
labels: { "#com.example/tags": ["draft", "urgent"] },
});
const ron = fromJSON(json, UseCustomVocabulary(invoiceVocabulary()));
// amount {#com.example/money [USD '12.50']}
// labels {#com.example/tags [draft urgent]}
// rating {#com.example/rating 5}
toJSON(ron, UseCustomVocabulary(invoiceVocabulary()));
// {"amount":{"#com.example/money":["USD","12.50"]}, ...}Note '12.50' is single-quoted in RON — a string that looks like a number must
be quoted so it isn't mistaken for one on the way back. fromJSON does this for you.
Validation. A registered parse function throws VocabError on a bad payload,
so enabling a vocabulary is also a validation gate:
fromJSON(
JSON.stringify({ rating: { "#com.example/rating": "not-a-number" } }),
UseCustomVocabulary(invoiceVocabulary()),
); // throws VocabError: invalid custom rating payloadPass-through. A #-prefixed key that no registered vocabulary claims is left as
an ordinary object — it round-trips but is never validated or transformed:
fromJSON(JSON.stringify({ amount: { "#com.example/money": ["USD", "12.50"] } }));
// amount {#com.example/money [USD '12.50']} ← no vocab registered, no validationAuthoring your own. Use defineVocabulary — a typed builder that handles tag
dispatch and #-prefix normalisation, and passes each handler a ctx with the full
payload-coercion toolkit (asString, asArray, asNumber, numberAsFloat64,
numberAsInt64, number, tagged, recurse, fail). No internal imports needed:
import { defineVocabulary, fromJSON, UseCustomVocabulary } from "ron-node";
// Convert ["celsius", 36.5] into a single Kelvin number.
const temperatureVocabulary = defineVocabulary({
uri: "https://example.com/vocab/temperature/v1",
tags: {
"com.example/temp": (payload, ctx) => {
const arr = ctx.asArray(payload);
if (arr.items.length !== 2) ctx.fail("invalid #temp payload");
const unit = ctx.asString(arr.items[0]!);
const d = ctx.numberAsFloat64(arr.items[1]!);
if (d === undefined) ctx.fail("invalid #temp value");
const kelvin = unit === "celsius" ? d + 273.15
: unit === "fahrenheit" ? (d - 32) * 5 / 9 + 273.15
: ctx.fail("unknown #temp unit");
return ctx.number(kelvin); // payload of {#com.example/temp ...}
},
},
});
fromJSON(
JSON.stringify({ body: { "#com.example/temp": ["celsius", 36.5] } }),
UseCustomVocabulary(temperatureVocabulary),
);
// body {#com.example/temp 309.65}The handler returns the payload that the framework wraps inside {#tag ...} — here a
RonNumber (via ctx.number), so the output is {#com.example/temp 309.65}.
["fahrenheit", 97.7] produces the same 309.65, i.e. the vocabulary normalises units.
Tags are keyed without the leading # (it's added automatically); "#com.example/temp"
is also accepted and normalised. Each handler can call ctx.recurse(value) to validate or
transform nested typed values through the same vocabulary machinery the built-ins use.
For authors who prefer the raw spec shape,
CustomVocabularySpecis still public andUseCustomVocabularyaccepts it directly — the helpers (asString,asArray,fail,numberAsFloat64, …) are now exported from the package root, so there is no need to reach intoron-node/src/vocab/util.
Unknown typed values are left as ordinary objects; enabling a vocabulary the registry does not support throws.
RonNumber(text)— a class that preserves number source text. JavaScript has no native decimal type, so storing the original text avoids IEEE-754 loss when round-tripping numbers like0.1or large integers.RonNumberonly carries its text; arithmetic is the caller's responsibility.Map-backedRonObject— uses delete-then-set on duplicate keys so the survivor moves to last position while preserving source insertion order. This is required forisCanonical=falserendering, where keys keep their source order instead of being sorted.RonArray.multiline— a flag driving the 80-byte inline-array rule: arrays below the threshold render inline, longer ones render one item per line.- ESM
.jsimport specifiers —module: NodeNextrequires explicit.jsextensions on relative imports even though the source is.ts. This is a TypeScript/NodeNext requirement. - Streaming transcoders —
fromJSONBytes/fromJSONStream(JSON→RON) andtoJSONBytes/toJSONStream(RON→JSON) lex one format and emit the other's compact bytes directly without building aValuetree, faster than the tree-basedfromJSON/toJSONfor compact output.fromJSON/toJSON(string-returning, tree-based) remain the full-featured paths that handle pretty mode, vocabularies, and the conformance suite. - Pure-TS RFC 8785 —
canonicalJSONis implemented from scratch with no dependency, so the canonical-JSON and canonical-RON paths share nothing but the SHA-256 fromnode:crypto.
For people working on ron-node, not just using it.
The testdata submodule is pinned to starfederation/ron:
git clone --recurse-submodules https://github.com/<you>/ron-node.git
# if already cloned:
git submodule update --init --recursive
pnpm installsrc/
ron/parse.ts RON parser
ron/render.ts RON renderer (compact + pretty)
json/parse.ts JSON parser (preserves number text)
json/render.ts JSON renderer (compact + pretty, canonical key sort)
json/rfc8785.ts pure-TS RFC 8785 canonicalization
vocab/ registry + per-vocabulary validators
registry.ts vocab state, tag normalization, dispatch
builder.ts defineVocabulary() + VocabularyContext
core.ts time.ts network.ts math.ts spatial.ts geo.ts color.ts set.ts
custom.ts invoiceVocabulary template (uses defineVocabulary)
profile.ts validateVocabularyProfile
types.ts VocabError, VocabState, CustomVocabularySpec, Recurse
util.ts shared numeric/array coercion helpers (exported)
value.ts Value model: RonNumber / RonObject / RonArray
options.ts Options + defaults
stream.ts streaming transcoders: JSON<->RON compact (no Value tree)
index.ts public surface
test/ conformance + rfc8785 + vocabularies + stream + builder (297 tests)
bench/ throughput harness
pnpm build # tsc -> dist/
pnpm typecheck # tsc --noEmit
pnpm test # vitest run (218 tests across 5 files)
pnpm test:watch # vitest in watch mode
pnpm lint # biome check src test
pnpm format # biome format --write src test
pnpm bench # throughput harness (see bench/README.md)Three corpora live under testdata/testdata/: conformance/, rfc8785/, and
vocabularies/ (the submodule). Tests exact-match RON↔JSON bytes and SHA-256 hashes;
vocabulary tests do structural comparison.
Override the location with:
RON_TESTDATA_DIR=/path/to/ron/testdata pnpm testUse src/vocab/color.ts as a template: implement a parse*Payload(tag, payload, state, recurse)
function returning the validated (or transformed) Value, export a Vocabulary<Color>V1 URI
constant, and register it in the VOCAB map in src/vocab/registry.ts. It then flows through
EnableVocabularies / validateVocabularyProfile automatically.
Biome (config in biome.jsonc); strict TypeScript with noUncheckedIndexedAccess,
noImplicitOverride, and noFallthroughCasesInSwitch. ESM with .js import specifiers is
required (NodeNext).
git submodule update --remote testdata
pnpm test # re-run, fix any impl changes, commit the new SHASee bench/README.md for how to run pnpm bench and reproduce the Go
reference numbers.
Measured on the 256-record synthetic document from ron-go/bench_test.go (the exact same bytes
on both sides), on one machine (Node 24 / Go 1.26):
| Conversion | ron-node (Node 24) | ron-node stream | ron-go (Go 1.26) |
|---|---|---|---|
| RON -> JSON (compact) | ~43 MB/s | ~56 MB/s | ~269 MB/s |
| JSON -> RON (compact) | ~40 MB/s | ~82 MB/s | ~83 MB/s |
| JSON -> RON (pretty) | ~38 MB/s | — | ~31 MB/s |
| canonical hash | — (uses stream) | ~80 MB/s | — (no Go bench) |
The streaming transcoders match or exceed Go on compact JSON→RON (sorted keys), and the
RON→JSON stream is ~1.3× faster than the tree path. The canonical hash path now runs at
~80 MB/s (up from ~41 MB/s). On pretty JSON→RON ron-node remains faster than the Go
reference. Run npm run bench and go test -bench=. -benchmem (in a ron-go checkout)
to reproduce locally.
This port aligns with ron-go (the reference) and
php-ron (the closest sibling port). Once published, the
intent is to add ron-node to the upstream implementations table in
starfederation/ron.
MIT