Skip to content

Mortalife/ron-node

Repository files navigation

ron-node

Node License Tests

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.

Requirements

  • Node ≥ 22
  • ESM-only ("type": "module")

Install

pnpm add ron-node

How to use

Quick start

import { 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"}

API reference

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 replacement

Vocabulary 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 .pos byte offset.
  • JSONParseError — JSON parse failure, carries a .pos byte offset.
  • RFC8785Error — RFC 8785 canonicalization failure.
  • VocabError — vocabulary validation failure.

Typed value hooks

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.

Streaming (compact mode)

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.

Typed vocabularies

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 vocabularies

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 payload

Pass-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 validation

Authoring 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, CustomVocabularySpec is still public and UseCustomVocabulary accepts it directly — the helpers (asString, asArray, fail, numberAsFloat64, …) are now exported from the package root, so there is no need to reach into ron-node/src/vocab/util.

Unknown typed values are left as ordinary objects; enabling a vocabulary the registry does not support throws.

Design decisions (Node-specific)

  • 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 like 0.1 or large integers. RonNumber only carries its text; arithmetic is the caller's responsibility.
  • Map-backed RonObject — uses delete-then-set on duplicate keys so the survivor moves to last position while preserving source insertion order. This is required for isCanonical=false rendering, 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 .js import specifiersmodule: NodeNext requires explicit .js extensions on relative imports even though the source is .ts. This is a TypeScript/NodeNext requirement.
  • Streaming transcodersfromJSONBytes / fromJSONStream (JSON→RON) and toJSONBytes / toJSONStream (RON→JSON) lex one format and emit the other's compact bytes directly without building a Value tree, faster than the tree-based fromJSON / toJSON for 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 8785canonicalJSON is implemented from scratch with no dependency, so the canonical-JSON and canonical-RON paths share nothing but the SHA-256 from node:crypto.

Developer guide

For people working on ron-node, not just using it.

Clone & setup

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 install

Repo layout

src/
  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

Commands

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)

Test corpus

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 test

Adding a vocabulary validator

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

Code style

Biome (config in biome.jsonc); strict TypeScript with noUncheckedIndexedAccess, noImplicitOverride, and noFallthroughCasesInSwitch. ESM with .js import specifiers is required (NodeNext).

Updating the corpus

git submodule update --remote testdata
pnpm test   # re-run, fix any impl changes, commit the new SHA

Benchmarking

See bench/README.md for how to run pnpm bench and reproduce the Go reference numbers.

Performance

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.

Conformance & implementations

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.

License

MIT

About

Node implementation of RON (Readable Object Notation): JSON's value model with lighter syntax (for humans and LLMs)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors