CBOR
CBOR — Concise Binary Object Representation, RFC 8949 — lives in runtime:serialization and runs in Rust: bytes in, a JavaScript value out, with no npm package to add. It shares MessagePack's binary footprint but carries a wider range of values and a defined deterministic form, which is what this implementation encodes.
Decoding CBOR
Use CBOR.decode to turn a CBOR byte array into a JavaScript value.
import { CBOR } from "runtime:serialization"; const bytes = new Uint8Array([ 0xa1, 0x65, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x65, 0x77, 0x6f, 0x72, 0x6c, 0x64, ]); const value = CBOR.decode(bytes); console.log(value); // { hello: "world" }
Both definite-length and indefinite-length strings, arrays and maps are accepted, so a document produced by any conforming encoder reads back. The whole input must be consumed: a trailing byte is a SyntaxError, not silently ignored.
Encoding CBOR
Use CBOR.encode to write a JavaScript value as CBOR bytes.
import { CBOR } from "runtime:serialization"; const bytes = CBOR.encode({ hello: "world" }); console.log(bytes); // Uint8Array(13) [161, 101, 104, 101, 108, 108, 111, 101, 119, 111, 114, 108, 100]
Encoding is the deterministic form of RFC 8949 §4.2:
integers and lengths use the shortest form that holds the value (the integer forms cover their full ranges; major type 1 reaches −264);
a float uses the shortest width that preserves it exactly — half, single, or double — with the canonical half-precision NaN
f97e00;map keys are sorted by the bytewise lexicographic order of their encodings.
The same value therefore always encodes to the same bytes. Because keys are sorted, a document you encode and decode comes back with its keys in canonical order, not the order you wrote them in:
const bytes = CBOR.encode({ b: 1, a: 2, aa: 3 }); // Keys sort by their encoded bytes: the length prefix sorts first, so the order // is "a", "b", "aa" — not the insertion order, and not plain lexicographic order. console.log([...bytes].map((b) => b.toString(16).padStart(2, "0")).join(" ")); // a3 61 61 02 61 62 01 62 61 61 03
What maps to what
An integer up to 253 − 1 comes back a JavaScript number; the first integer past that is a BigInt, so no CBOR integer is ever rounded. encode writes a BigInt back as an integer while it fits the range the major types cover, [−264, 264-1]; a BigInt beyond that would need a bignum tag, which is refused, so it throws.
| JavaScript | Wire | Comes back as |
|---|---|---|
Uint8Array, any typed-array view, ArrayBuffer | byte string (major 2) | Uint8Array |
Map | map, keys stringified | plain object |
Set | array | Array |
Date | text string (ISO 8601) | string, not a Date |
undefined | 0xf7 | undefined |
null | 0xf6 | null |
BigInt | integer (major 0/1), within ±264 | bigint past 253, else number |
| function, symbol | — | throws TypeError |
Map, Set and ArrayBuffer are converted because they reach the host as empty objects — they carry no own enumerable properties, so without the conversion every entry would be silently dropped. A non-string CBOR map key is coerced the way a JavaScript property access coerces it — numbers with String(n), arrays and byte strings as comma-joined elements, a nested map as [object Object] — so keys that coerce alike share a name. An integer-valued number encodes as an integer, so a float that happens to be whole (a decoded 100000.0) re-encodes as the integer 100000. A value with no CBOR form throws rather than being written as null, since a silent substitution is exactly the data loss a format chosen for fidelity exists to avoid.
Tags are refused
CBOR's major type 6 attaches a tag — a number giving a value a semantic meaning, such as a date (0/1), a bignum (2/3), or a decimal fraction (4). No JavaScript type corresponds to a CBOR tag, and inventing a wrapper object would make a tagged value indistinguishable from real data the document contained. So decode refuses a tag and reports its number, and encode never writes one:
CBOR.decode(new Uint8Array([0xc0, 0x61, 0x61])); // SyntaxError: Parse failed: unsupported tag 0 (tags have no JavaScript counterpart)
The same reasoning refuses an unassigned simple value (major type 7), for which there is likewise no JavaScript counterpart. Tags are a candidate to revisit — a future Date mapping would decode tag 1, for instance — but the readiness check and the encoder would both have to change together.
Validating CBOR
CBOR.validate checks a byte array without your having to catch a throw. It reads the document exactly as decode does, so the two always agree: a document the decoder would refuse (a tag, a trailing byte) is not reported valid.
import { CBOR } from "runtime:serialization"; CBOR.validate(new Uint8Array([0x00])); // true CBOR.validate(new Uint8Array([0xc0, 0x61, 0x61])); // false — a tag const result = CBOR.validate(new Uint8Array([0x00, 0x00]), { detailed: true }); console.log(result.valid); // false console.log(result.error); // "Validation failed: bytes remain after the top-level value"
How it compares to MessagePack
MessagePack and CBOR cover much the same ground, and this module treats them identically where they overlap: a byte string decodes to a Uint8Array, a Map and a Set are converted rather than flattened, and an unrepresentable value throws. The differences are the ones the formats themselves have:
| MessagePack | CBOR | |
|---|---|---|
| Encoding | shortest forms, but no defined canonical output | deterministic (RFC 8949 §4.2) |
undefined | encoded as nil | undefined (0xf7), distinct from null |
| Extensions | ext, decoded as raw bytes | tags, refused |
| Float width | always float64 | shortest (half / single / double) |
| Spec | a compact de-facto standard | an IETF standard (RFC 8949) |
Reach for CBOR when a standard's canonical form matters — a signature or a content address over the encoded bytes — or when you need undefined to survive distinctly from null. Reach for MessagePack when you want the ext family to pass through as bytes.
How it compares to other libraries
CBOR is an IETF standard, so the encoders agree on the wire and part company on what JavaScript types come back — and, past the safe integer range, on whether the value survives at all.
| Outside-safe integers | Tags | Canonical output | Ships as | |
|---|---|---|---|---|
| ES-Runtime | bigint (past 253 − 1) | refused | always | runtime:serialization |
Deno @std/cbor | bigint (from 232) | CborTag | no (always float64) | a JSR package |
| cborg | bigint (past ±253) | Tag | smallest forms, map order free | an npm package |
| cbor-x | bigint (opt out with int64AsNumber) | extension tags | no (record extension) | an npm package |
| node-cbor | BigInt (bignum tags 2/3) | to native types | opt-in (encodeCanonical) | an npm package |
The integer boundary here is the field's: as in cborg and cbor-x, a small integer stays a number and the first one past 253 − 1 becomes a BigInt, so the exact value survives a round trip. Deno's @std/cbor draws the line lower, at 232, so it returns a bigint for a value this keeps as an exact number. A BigInt inside the safe range encodes as an integer and comes back a number — the wire has no BigInt type to return — which is the same round-trip cborg documents.
Two differences are deliberate. The 64-bit range is the end of the road: a value past 264-1 or −264 throws, where the others carry it in a bignum tag (2/3) — the same refusal a Date gets, for the same reason. And the canonical form is what encode always writes rather than an option you turn on (node-cbor's encodeCanonical), because a deterministic encoding is the point of choosing CBOR here.
The benchmarks measure this decoder against the facility each other runtime ships — esrun's native CBOR, Deno's @std/cbor, and cbor-x on Node and Bun — from the committed results rather than a number typed here.
See also
runtime:serializationreference — signatures and the type mappingInternals: serialization — why the codec works the way it does