npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ron-node

v0.0.2

Published

Readable Object Notation for Node.js — port of starfederation/ron-go

Readme

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

Not published yet — package name ron-node is a placeholder until ready.

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