@claudewerk/envelope-codec
v0.2.0
Published
Pure encode/decode codec for { env, body? } messages. Envelope is channel-encoded (JSON or CBOR); body is opaque octets passed through byte-for-byte.
Maintainers
Readme
envelope-codec
A standalone, dependency-free codec for messages of shape { env, body? }.
The whole point: a message is an envelope (structured routing/control metadata) plus an optional body (opaque octets). The codec encodes ONLY the envelope in the channel encoding; the body is passed through byte-for-byte and never decoded or inspected. That is what lets a body be any encoding, or sealed/encrypted, at zero cost to the codec.
It knows nothing about WebSockets, queues, or transports. It is a pure
encode/decode pair. The Codec shape is duck-type compatible with what a
reliable-connection transport expects -- but nothing here imports such a library.
- Runtime deps: zero.
cbor-xandpinoare OPTIONAL peer dependencies, lazily required. The package works fully with neither installed (JSON-only). - Bun (
bun test,bunruntime).
What it supports
Everything below is implemented and covered by bun test (51 tests):
- Two channel encodings for the envelope -- JSON (mandatory baseline, UTF-8
JSON text) and CBOR (RFC 8949, optional via
cbor-x). - Opaque body, byte-for-byte. JSON carries a small body inline as base64 or,
in out-of-band mode, as a length/
contentTypedescriptor only. CBOR carries a binary body as a native untagged byte string -- no base64 tax. Decode returns the exact input octets in every mode; the codec never parses the body. - 1-byte binary-frame discriminator (
0x01envelope /0x02raw chunk, with a documented registry for control/application frames), resolved BEFORE any decode so the receiver never trial-parses. - Pass-through mode -- wrap an arbitrary opaque frame as the body under a default envelope, so the codec can back a transport of raw bytes.
- Hardened decode of untrusted input -- size cap before decode, max nesting
depth, max collection size, duplicate-key rejection, and (CBOR) rejection of
indefinite lengths and tags (no tag->class instantiation). Every failure is a
typed
CodecError, never a leaked throw or a hang. - Encoding negotiation -- preference-ordered and version-aware; peers with different local orderings still agree without a round trip.
- Injectable logging -- silent by default;
consoleor optionalpino.
Install
bun add @claudewerk/envelope-codec
bun add cbor-x # optional: enables the CBOR channel
bun add pino # optional: structured loggingUsage
import { jsonCodec, cborCodec, negotiate, codecFor } from '@claudewerk/envelope-codec';
const enc = negotiate(['cbor', 'json'], theirAdvertised); // 'cbor' | 'json'
const codec = codecFor(enc);
const wire = codec.encode({
env: { to: 'node-7', kind: 'sync', seq: 9 },
body: new Uint8Array([0xde, 0xad, 0xbe, 0xef]), // opaque octets
contentType: 'application/octet-stream',
});
const msg = codec.decode(wire);
// msg.env -> { to: 'node-7', kind: 'sync', seq: 9 } (opaque object, not interpreted)
// msg.body -> Uint8Array [0xde,0xad,0xbe,0xef] (exact input bytes, untouched)
// msg.contentType -> 'application/octet-stream'Codec interface
interface Codec {
encoding: 'json' | 'cbor';
encode(msg: { env: object; body?: Uint8Array; contentType?: string }): Uint8Array | string;
decode(data: Uint8Array | string): { env: object; body?: Uint8Array; contentType?: string; bodyBytes?: number };
}
jsonCodec(opts?): Codec;
cborCodec(opts?): Codec; // throws CodecError('CBOR_UNAVAILABLE') if cbor-x is absent
negotiate(ours: string[], theirs: string[]): 'json' | 'cbor';
codecFor(encoding, opts?): Codec;Options (both codecs): { maxBytes, limits: { maxDepth, maxItems }, log, passthrough }.
jsonCodec also takes { oob } (out-of-band body, see below).
In pass-through mode (passthrough: true) encode() also accepts a raw
Uint8Array. See Pass-through mode.
Wire format
The env is opaque -- the codec never reads its fields. Both channels serialize
the same logical wire object with keys env, optional contentType, and an
optional body carried differently per channel.
JSON channel (mandatory baseline)
The envelope is UTF-8 JSON text:
{
"env": { "...": "..." },
"contentType": "application/octet-stream",
"body": "<base64 of the opaque body>"
}A binary body rides inline as base64 under body. base64 round-trips the
exact octets, so decode restores them unchanged; the bytes are never parsed.
Out-of-band mode (jsonCodec({ oob: true })): no bytes are inlined; only a
descriptor is emitted and the body travels on a side channel.
{ "env": { "...": "..." }, "contentType": "video/mp4", "bodyBytes": 1048576 }Decode then returns { env, contentType, bodyBytes } and no body.
CBOR channel (RFC 8949, optional)
The envelope is a CBOR map; a binary body is a native CBOR byte string (major type 2) -- no base64 tax:
map(2) {
"env" => map(...) ; the opaque envelope
"body" => bytes(N) ; major type 2, untagged -- the opaque body
}
; "contentType" => text ; present when setThe body byte string is untagged (header 0x40 | len), so it is a plain byte
string, not a tagged/typed-array value.
Binary-frame discriminator (1 byte)
In binary (CBOR) mode a caller may interleave envelope messages with raw opaque chunks (e.g. bulk transfer). A single leading byte distinguishes them and is resolved BEFORE any decode, so the receiver never trial-parses:
Discriminator registry (owned by this package; a composing protocol must
stay within it). This codec emits and consumes only 0x01/0x02; the rest are
reserved so higher layers can add frames without ever reassigning these two.
| Byte(s) | Class | Meaning |
| ------------ | ------------- | ------------------------------------ |
| 0x00 | reserved | never emit |
| 0x01 | envelope | an encoded { env, body? } |
| 0x02 | chunk | raw/opaque bytes, never decoded |
| 0x03..0x0f | control | reserved for protocol control frames |
| 0x10..0xff | application | application-defined |
frameType(bytes) routes only the two bytes this codec owns (returns
'envelope'/'chunk', throws BAD_FRAME otherwise). frameClass(byte)
classifies any byte against the registry (reserved/envelope/chunk/
control/application) for a higher layer's own routing.
import { framed, frameType, encodeChunk, decodeChunk } from '@claudewerk/envelope-codec';
const codec = framed(cborCodec()); // encode() now prepends 0x01
const wire = codec.encode({ env, body });
// receiver: route on the discriminator, THEN decode -- no trial parse
switch (frameType(wire)) {
case 'envelope': return codec.decode(wire);
case 'chunk': return handleRaw(decodeChunk(wire));
}Design note: the spec said "expose it as an option".
frameType()+framed()is a deliberate improvement -- the discriminator MUST be resolved by the receiver before decode, and a decode-time option cannot do that without already committing to a parse. Routing is therefore a separate, pre-decode step.
Hardened decode (untrusted input)
Every decode path enforces limits and turns any failure into a typed
CodecError (never a leaked throw or a hang):
| Guard | JSON | CBOR | CodecError.code |
| ----------------------------- | :--: | :--: | ------------------- |
| Size cap before decode | ✓ | ✓ | TOO_LARGE |
| Max nesting depth | ✓ | ✓ | TOO_DEEP |
| Max items per collection | ✓ | ✓ | TOO_MANY_ITEMS |
| Duplicate keys rejected | ✓ | ✓ | DUP_KEY |
| Indefinite-length rejected | -- | ✓ | INDEFINITE_LENGTH |
| Tags rejected (no class inst.) | -- | ✓ | UNSUPPORTED |
| Malformed input | ✓ | ✓ | MALFORMED |
CBOR input is structurally scanned by a hand-written pass (cbor-scan.js) BEFORE
cbor-x ever touches it, so no attacker-controlled shape is instantiated and no
tag maps to a class. JSON is scanned before JSON.parse to catch depth and
duplicate keys, which JSON.parse alone cannot.
Defaults: maxBytes = 16 MiB, maxDepth = 64, maxItems = 100000. Override via
the limits / maxBytes options.
Pass-through mode
To back a transport whose frames are ARBITRARY bytes (not already
{ env, body }), enable passthrough. encode() then also accepts a raw
Uint8Array and wraps it as the body under a default (empty) envelope, so not
every frame has to be an envelope to flow through the codec.
const codec = cborCodec({ passthrough: true }); // or an env object: { passthrough: { src: 'transport' } }
codec.encode(new Uint8Array([1, 2, 3])); // -> encodes { env: {}, body: <those bytes> }
codec.encode({ env: { k: 1 }, body }); // normal messages still work
const { env, body } = codec.decode(wire); // body is the exact opaque frame backWithout passthrough, feeding raw bytes to encode() is a CodecError
(MALFORMED) -- a message is required.
Negotiation
Preference-ordered and version-aware, so two peers with different local orderings still agree without a round trip.
negotiate(ours, theirs) // -> 'json' | 'cbor'- Canonical
PREFERENCE = ['cbor', 'json'](most-preferred first) is owned by this package and exported. - Advertised encodings are tokens
name[/version](e.g.'cbor/1'); matching is by name. - Result = the first name in
PREFERENCEthat BOTH sides advertise, availability-gated (cboronly if thecbor-xpeer dep is present here). - JSON is the always-available fallback.
negotiate(['json', 'cbor'], ['cbor', 'json']) // -> 'cbor' (order-independent)
negotiate(['cbor/1'], ['cbor/2']) // -> 'cbor' (version-agnostic match)
negotiate(['cbor'], ['json']) // -> 'json'Logging
Injectable Logger (debug/info/warn/error); silent by default. consoleLogger
and pinoLogger() (lazy, optional pino) are provided. Pass any object with the
four methods as { log }.
License
MIT
