@retrovm/cybon
v0.5.0
Published
Compact binary serialization format (Cybon) encoder/decoder for TypeScript
Readme
@retrovm/cybon
TypeScript implementation of Cybon, a compact binary serialization format similar in spirit to JSON, with two extras: real 64-bit integers and in-document references (so shared or circular object graphs serialize without duplication or infinite loops). Also includes Cyton, its human-readable textual twin (same data model, JSON-like text).
Ported from the reference C implementation (fl_cybon.h / fl_cyton.h). Pure standard JS (Uint8Array, DataView, TextEncoder/TextDecoder) — no Node or Bun-specific APIs, so it runs in Node, Bun, Deno, and browsers alike.
Install
npm install @retrovm/cybonUsage
import { CybonDecoder, CybonEncoder } from "@retrovm/cybon";
const bytes = new CybonEncoder().encode({
name: "cybon",
version: 1,
tags: ["binary", "compact"],
data: new Uint8Array([1, 2, 3]),
});
const value = new CybonDecoder(bytes).decode();CybonEncoder/CybonDecoder also expose the item-level building blocks the high-level encode()/decode() methods are built on (beginArray/addString/end, unsigned/string/object/resolve, ...) for anyone who wants to write or walk a buffer by hand instead of going through a CybonValue.
Supported types
| TypeScript | Cybon wire type |
| ---------------------------------- | ---------------- |
| number | NUMBER (smallest integer width that fits, or float) |
| bigint | NUMBER (64-bit integer) |
| string | STRING (UTF-8) |
| boolean | MARK (true/false) |
| null | MARK (null) |
| Uint8Array | BINARY |
| Array<CybonValue> | ARRAY |
| { [key: string]: CybonValue } | OBJECT (string keys) |
| Map<CybonKey, CybonValue> | OBJECT (string, number, or bigint keys) |
Notes:
- Integers that fit in
Number.isSafeIntegerrange decode back asnumber; larger 64-bit integers decode asbigint. Encoding abigintoutside the 64-bit signed/unsigned range throws. - Non-integer or unsafe-magnitude numbers are encoded as
F32(if exactly representable) orF64. undefinedobject properties are omitted (matchingJSON.stringify);undefinedarray elements are encoded asnull.- The wire format has no int/bigint/float-width distinction beyond value range —
encode()/decode()(and the Cyton methods below) are round-trip-value-safe, not round-trip-byte-safe: encoding5nand decoding it back gives5(anumber), and the original wire width (e.g.U8vsU16vs inline) isn't preserved. Don't rely on re-encoded output being byte-identical to the original (e.g. for hashing or signing) even when the value round-trips exactly. - Object keys: Cybon supports NUMBER or STRING keys. A plain
{ [key: string]: CybonValue }object only ever has string keys — this is whatencode()produces for a plain JS object, and whatdecode()produces for a document whose object keys are all STRING. A document with at least one NUMBER key decodes as aMap<CybonKey, CybonValue>instead, so a numeric key never collides with a same-valued string key. To encode a NUMBER key yourself, pass aMap. - A single buffer/text document holds exactly one top-level value;
decode()/fromCyton()throw on trailing data. Cybon buffers are concatenable (several documents back-to-back) — usedecodeStream(): CybonValue[]to read all of them. EXTitems (reserved for future extension types) decode as opaqueUint8Array, same asBINARY, instead of failing — matching the format's guarantee that EXT is always safely skippable by a reader that doesn't understand it.- A
REFcan't point at anotherREF; that's rejected as malformed rather than followed.
Shared and circular references
If the same object or array instance (===) appears more than once in the value you encode, it is written once and referenced afterwards via Cybon's REF item — this also means circular structures (an object that (indirectly) contains itself) can be encoded without infinite recursion. Decoding restores the same object identity at every place the reference occurred.
const shared = { id: 1 };
const value = { a: shared, b: shared };
const decoded = new CybonDecoder(new CybonEncoder().encode(value)).decode() as { a: object; b: object };
decoded.a === decoded.b; // trueNumeric object keys
import { CybonDecoder, CybonEncoder } from "@retrovm/cybon";
const map = new Map<string | number, unknown>([[5, "five"], ["five", "the word"]]);
const decoded = new CybonDecoder(new CybonEncoder().encode(map)).decode() as Map<unknown, unknown>;
decoded.get(5); // "five"
decoded.get("five"); // "the word"Concatenated documents
import { CybonDecoder, CybonEncoder } from "@retrovm/cybon";
const enc = new CybonEncoder();
const bytes = new Uint8Array([...enc.encode(1), ...enc.encode("two"), ...enc.encode([3])]);
new CybonDecoder(bytes).decodeStream(); // [1, "two", [3]](enc.encode() resets the encoder's buffer on every call, so the same instance can be reused - each call above returns its own standalone document, concatenated by hand into one buffer.)
Cyton (text format)
Cyton is a pure binary↔text bridge: CybonEncoder.prototype.toCyton() converts the buffer an encoder has already built to Cyton text, and CybonDecoder.fromCyton() (static) parses Cyton text back into a Cybon binary buffer - decode that buffer normally to get a CybonValue.
import { CybonDecoder, CybonEncoder } from "@retrovm/cybon";
const enc = new CybonEncoder();
enc.encode({ name: "cybon", data: new Uint8Array([1, 2, 3]) });
const text = enc.toCyton({ pretty: true });
const value = new CybonDecoder(CybonDecoder.fromCyton(text)).decode();Differences from JSON:
- Binary values are written as
<base64...>(angle brackets, no quotes). - Shared/circular references are written as a dot-chain path prefixed with
@(absolute, from the document root), e.g.@"a".[0]; an empty path (just@) means "self".toCyton()emits these automatically for repeated object/array identities, exactly likeencode()does for the binary format. NaN/Infinityhave no literal in the grammar. Encoding one to binary works fine (encode()supports NaN/Infinity floats);toCyton()is what throws, since that's the step that actually can't represent it as text.pretty: true(defaultfalse) indents the output multi-line; compact output is a single line.
Errors
CybonDecoder.prototype.decode()/decodeStream() and CybonDecoder.fromCyton() throw CybonDecodeError on truncated, malformed, or otherwise invalid input rather than reading out of bounds or crashing.
Build
bun run build # emits dist/cybon.js (ESM) and dist/cybon.d.ts
bun test