CBOR (RFC 8949) encoder and decoder for JavaScript, with no dependencies.
The thing that makes it different is what it remembers. Most decoders hand you a value and throw the encoding away. cbors can hand you an annotation tree instead: every item, primitives included, still knows which bytes it came from. That matters on Cardano, where a transaction is hashed and signed over its exact bytes and re-encoding a decoded value is not guaranteed to give you those bytes back.
v2 is not a drop-in upgrade. It is ESM-only and needs Node >= 22.12, and the API, the number type, and the byte type all changed, so v1 code will not run unchanged against it. Migrating from v1 lists every change and its replacement.
Moving to v2 is the recommended path: it is faster than v1 (see Benchmarks), carries no dependencies and no Node.js builtins, and it will get new features and fixes from here on.
v1 remains on npm and keeps working. Pin the major version to stay there:
yarn add @stricahq/cbors@1
<script src="https://cdn.jsdelivr.net/npm/@stricahq/cbors@1/dist/index.min.js"></script>
v1 is a CJS + ESM dual package, runs on older Node, and uses bignumber.js and Buffer. Everything below documents v2.
yarn add @stricahq/cbors
<script src="https://cdn.jsdelivr.net/npm/@stricahq/cbors/dist/index.min.js"></script>
// access the cbors global variable
cbors has no dependencies and uses no Node.js builtins, so bundlers need no polyfills or aliases for it.
import { encode, decode } from "@stricahq/cbors";
const bytes = encode(new Map().set(0, [1, 2])); // Uint8Array a1 00 82 01 02
const value = decode(bytes); // Map(1) { 0 => [ 1, 2 ] }
decode returns the value directly, and throws if the input holds anything other than exactly one complete CBOR item.
That is the whole surface for simple work. Everything else the package exports:
import {
encode, // value -> Uint8Array
decode, // bytes -> value
decodeAnnotated, // bytes -> CborNode tree, keeps the original bytes of every item
IncrementalDecoder, // push chunks, get items as they complete
CborTag, // a tagged value
SimpleValue, // a simple value other than true/false/null/undefined
EncodedCbor, // pre-encoded bytes to splice into the output as-is
IndefiniteArray, // Array subclass that encodes in indefinite form
IndefiniteMap, // Map subclass that encodes in indefinite form
} from "@stricahq/cbors";
For more examples, the tests cover every supported data type.
decode(bytes);
| CBOR | you get |
|---|---|
| integer within ±2^53 | number |
| integer beyond ±2^53 | bigint |
| bignum (tags 2 and 3) | bigint |
| float16 / float32 / float64 | number |
| byte string | Uint8Array |
| text string | string |
| array | Array, or IndefiniteArray if it was indefinite |
| map | Map, or IndefiniteMap if it was indefinite |
| any other tag | CborTag { tag, value } |
true / false / null / undefined |
the same, unwrapped |
| any other simple value | SimpleValue { value } |
Maps always come back as a Map, never a plain object, because CBOR keys are not limited to strings. A decoded byte string is a zero-copy view into the buffer you passed in, so it stays valid as long as that buffer does.
encode(value);
| you pass | you get |
|---|---|
number, integral |
integer, in the smallest head that fits |
number, fractional |
float64 |
NaN, ±Infinity, -0 |
the two-byte float16 forms |
bigint |
integer if it fits in 64 bits, else a bignum tag |
string |
text string |
Uint8Array, Buffer, ArrayBuffer, Uint8ClampedArray |
byte string |
Array / IndefiniteArray |
definite / indefinite array |
Map / IndefiniteMap |
definite / indefinite map |
| plain object | map with text keys |
CborTag |
tagged item |
SimpleValue |
simple value |
EncodedCbor |
its bytes, spliced in untouched |
Date, Set, RegExp, other typed arrays, functions, symbols |
throws |
Anything the encoder cannot represent faithfully throws rather than being silently mangled into a map of its fields. Note that an integral number always encodes as an integer, so 1.0 goes out as 01, and floats are never shrunk to half or single precision. If you need a specific float encoding on the wire, splice it in with EncodedCbor.
Indefinite-length arrays and maps survive a full round trip: they decode to IndefiniteArray / IndefiniteMap, which encode back to indefinite form.
import { IndefiniteArray } from "@stricahq/cbors";
const list = new IndefiniteArray();
list.push(1, 2);
encode(list); // 9f 01 02 ff
encode(decode(encode(list))); // 9f 01 02 ff, still indefinite
Indefinite byte and text strings are the exception: they decode to a single joined Uint8Array / string, so a re-encode produces the definite form. If you need those bytes back exactly, use the annotation tree below.
Everything on Cardano is hashed and signed over exact CBOR bytes, and a value has more than one valid encoding. The integer 23 can be written as 17 or as 1817. An array can be definite or indefinite. Both are legal, both decode to the same thing, and they hash to different values. So decoding a transaction and encoding it again is not a safe way to move it around: you may get different bytes, and different bytes mean a different transaction id or a broken signature.
cbors handles both directions of this problem: decodeAnnotated on the way in, EncodedCbor on the way out.
decodeAnnotated returns a CborNode tree instead of a plain value. Every item carries its kind, its byte span in the source buffer, and the head-byte encoding info. node.bytes is a zero-copy slice of the original bytes of that item, header included.
So a transaction id is the hash of the body node's bytes:
import { decodeAnnotated } from "@stricahq/cbors";
// tx = [body, witnessSet, isValid, auxiliaryData]
const tx = decodeAnnotated(txBytes);
const txId = blake2b256(tx.at(0).bytes); // any hash function
tx.at(0).bytes is the same stretch of memory as in txBytes, so the hash matches the on-chain id. The same trick covers datum hashes, script integrity hashes and anything else hashed over original bytes.
Walking a node:
node.at(k) — array index, or the value of the first map entry whose key matches k. Keys can be a number/bigint (they cross-match), a string, a bool, or a Uint8Array matched by content. Returns a CborNode or undefined.node.bytes — the exact source bytes for this item, header included.node.toJS() — the plain decode() value for this subtree (joins indefinite chunks, collapses bignum tags, last-wins for duplicate keys).node.kind — uint, nint, bytes, text, array, map, tag, simple, float, bool, null or undefined.node.value — the payload of a leaf. For a byte string that is the bytes without the header, unlike node.bytes.node.items — array children. node.entries — map entries, keeping order and duplicate keys. node.chunks — the pieces of an indefinite byte/text string. node.child — a tag's payload.node.span — [start, end) offsets into the input. node.encoding — { ai, indefinite }, the head byte's additional info and whether the item was indefinite.Reading a couple of fields out of a transaction body:
const body = tx.at(0);
const fee = body.at(2).toJS(); // body key 2 is the fee
const inputs = body.at(0); // body key 0 is the inputs
// since Conway, sets arrive wrapped in tag 258
const inputItems = inputs.kind === "tag" ? inputs.child.items : inputs.items;
decodeAnnotated is one-shot: spans are offsets into a single contiguous buffer.
EncodedCbor wraps a buffer that already holds one encoded CBOR item, and the encoder writes those bytes into the output untouched instead of re-encoding them. Any bytes, from anywhere, at any position in the value you are encoding.
import { encode, EncodedCbor } from "@stricahq/cbors";
const raw = encode(new Map().set(1, 2).set(3, 4)); // a2 01 02 03 04, from wherever
encode(new EncodedCbor(raw)); // a2 01 02 03 04, spliced in as-is
encode(raw); // 45 a2 01 02 03 04, a Uint8Array is a byte string
encode([1, new EncodedCbor(raw)]); // 82 01 a2 01 02 03 04, nests like any other value
Pair it with node.bytes from the annotation tree and you can rebuild a transaction with one subtree replaced, while every untouched subtree keeps its original bytes. Attaching a witness set from a CIP-30 wallet is exactly that:
import { decodeAnnotated, encode, EncodedCbor } from "@stricahq/cbors";
const tx = decodeAnnotated(txBytes);
const witnessSet = await api.signTx(txHex, true); // CIP-30, hex string
const signedTx = encode([
new EncodedCbor(tx.at(0).bytes), // body, spliced verbatim, its hash unchanged
new EncodedCbor(toBytes(witnessSet)), // fresh witness set from the wallet
true, // isValid
null, // auxiliaryData
]);
The buffer can come from anywhere: a wallet, a node, a cache, a slice of an annotation tree. It is spliced as given and never re-parsed or validated, so it has to be exactly one well-formed item.
cbors ships no hex helper of its own. Anything that produces a Uint8Array works: Buffer.from(hex, "hex") on Node, Uint8Array.fromHex(hex) on runtimes that have it, or whichever hex utility your project already uses.
Tags are first class in both directions. Anything cbors does not fold into a native value decodes to a CborTag, and a CborTag encodes back to the same tag number and payload.
import { encode, decode, CborTag } from "@stricahq/cbors";
const bytes = encode(new CborTag([1, 2, 3], 258)); // d9 0102 83 01 02 03
const tagged = decode(bytes);
tagged.tag; // 258
tagged.value; // [1, 2, 3], the payload decoded normally
encode(tagged); // the same bytes back
There is no tag registry and no way to register a decoder for a tag: cbors gives you the number and the payload, and your code decides what it means. The one exception is bignums, tags 2 and 3, which decode straight to bigint because there is no lossless way to hand them back otherwise.
Tag numbers are supported over the full CBOR range, up to 2^64 - 1. Numbers beyond 2^53 come back as a bigint and can be passed back to encode the same way.
Tags you will meet on Cardano:
| Tag | Used for | In cbors |
|---|---|---|
| 2, 3 | bignums | decode to bigint; encode emits them when a bigint exceeds 64 bits |
| 24 | a byte string that holds another CBOR item | new CborTag(bytes, 24); the payload stays a Uint8Array, run decode on it yourself if you want the value |
| 30 | rational numbers, e.g. protocol parameters | new CborTag([numerator, denominator], 30) |
| 258 | sets, since Conway | new CborTag(items, 258) |
| 121-127, 1280-1400, 102 | Plutus data constructors | plain CborTag, the alternative is in the tag number |
Decimal fractions (tag 4) work the same way, built explicitly:
encode(new CborTag([-1, 15], 4)); // c4 82 20 0f, i.e. 15 * 10^-1
IncrementalDecoder is a dependency-free push decoder for a stream of concatenated CBOR items, a chain-sync feed or a file of blocks. Feed it chunks as they arrive; each push returns the top-level items that completed in that chunk, as { value, bytes }. Chunk boundaries can fall anywhere, including in the middle of an item. Call end() when the input is done, and it throws if the stream stopped mid-item.
import { IncrementalDecoder } from "@stricahq/cbors";
const decoder = new IncrementalDecoder();
socket.on("data", (chunk) => {
for (const { value, bytes } of decoder.push(chunk)) {
// one entry per completed top-level item
}
});
socket.on("end", () => decoder.end());
To .pipe() it into a Node.js stream, wrap it in a Transform: push each chunk on transform, and end on flush. The decoder stays dependency-free; you bring the stream glue.
import { createReadStream } from "node:fs";
import { Transform } from "node:stream";
import { IncrementalDecoder } from "@stricahq/cbors";
const decoder = new IncrementalDecoder();
const decodeStream = new Transform({
readableObjectMode: true,
transform(chunk, _enc, cb) {
try {
for (const item of decoder.push(chunk)) this.push(item);
cb();
} catch (err) {
cb(err);
}
},
flush(cb) {
try {
decoder.end();
cb();
} catch (err) {
cb(err);
}
},
});
createReadStream("stream.cbor")
.pipe(decodeStream)
.on("data", ({ value, bytes }) => {
// one entry per completed top-level item
});
Options are plain objects and always optional.
decode(bytes, options), decodeAnnotated(bytes, options) and new IncrementalDecoder(options) take the same decoder options:
| Option | Default | What it does |
|---|---|---|
maxDepth |
1024 |
Rejects items nested deeper than this. Deep nesting is what a hostile input uses to blow the stack. |
maxStringLength |
4294967295 |
Rejects a byte or text string, or an indefinite chunk, that declares a length above this. Stops a two-byte header from asking for a gigabyte allocation. This is also the ceiling: a larger value is clamped to it. |
decode(bytes, { maxDepth: 32, maxStringLength: 1 << 20 });
encode(value, options):
| Option | Default | What it does |
|---|---|---|
collapseBigInt |
true |
Writes a bigint as a plain integer when it fits in 64 bits. Set it to false to always emit a bignum tag. |
encode(10n); // 0a
encode(10n, { collapseBigInt: false }); // c2 41 0a
The decoder is strict about well-formedness, and throws a plain Error rather than guessing:
maxDepth or maxStringLengthReal Cardano CBOR data, single threaded.
| Workload | Size | decode |
decodeAnnotated |
encode |
IncrementalDecoder |
|---|---|---|---|---|---|
| Smallest tx | 193 B | 351 MB/s | 266 MB/s | 200 MB/s | 172 MB/s |
| Median tx | 576 B | 521 MB/s | 411 MB/s | 287 MB/s | 303 MB/s |
| Largest tx | 16.0 kB | 410 MB/s | 748 MB/s | 612 MB/s | 404 MB/s |
| Full block (1 item, 15 txs) | 86.9 kB | 386 MB/s | 627 MB/s | 586 MB/s | 321 MB/s |
| 32 random blocks (32 items, 191 txs) | 162.4 kB | 358 MB/s | 331 MB/s | 272 MB/s | 266 MB/s |
A median 576 B transaction decodes in about 1.1 µs, a full 87 kB block in about 0.22 ms. decodeAnnotated tracks plain decode closely, and runs ahead of it on large map-heavy items.
Measured on an Apple M1 Pro, Node 24.13.0.
| v1 | v2 |
|---|---|
Encoder.encode(x) |
encode(x) |
Decoder.decode(x).value |
decode(x) (returns the value directly) |
getCborBytes(buf, item) / byte spans |
decodeAnnotated(buf) → node.at(...).bytes |
hasByteSpan / Spanned guards |
annotation tree carries spans for every item |
new Decoder() Transform stream |
new IncrementalDecoder() (push / end) |
BigNumber (via bignumber.js) |
native bigint (BigNumber inputs now throw) |
collapseBigNumber encode option |
collapseBigInt |
| decimal fractions via BigNumber | new CborTag([exponent, mantissa], 4) |
Buffer in and out |
Uint8Array out; Buffer still accepted as input |
buffer polyfill for browsers |
no dependencies, no Node.js builtins |
| CJS + ESM dual package | ESM only, require(esm) on Node >= 22.12 |
Find the API documentation here.
Copyright 2022 Strica
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.