JSON Codec

@observertc/samples-json-codec encodes each ClientSample as the difference from the last one — which is where nearly all of the saving comes from, since a WebRTC stats sample is mostly identifiers, codec parameters and slow-moving counters that repeat verbatim every tick.

A delta is shaped like a partial ClientSample. Nothing is renamed, tagged or wrapped, so you can read one without a decoder:

{
  "timestamp": 1756000001000,
  "clientId": "client-42",
  "peerConnections": [
    {
      "peerConnectionId": "pc-01",
      "inboundRtps": [{ "ssrc": 1234567890, "bytesReceived": 81234, "jitter": 0.011 }]
    }
  ]
}

Which codec do I want?

This one and @observertc/samples-protobuf-codec are the same codec over different wire formats. Same delta semantics, same error codes, same API shape — you can swap one for the other without changing anything downstream.

samples-json-codecsamples-protobuf-codec
Runtime dependenciesnone@bufbuild/protobuf
Bundle, minified + gzip~2.1 KB~29.8 KB
Payload, raw2.73×
Payload, gzipped1.41×
Readable on the wireyesno

Measured on a recorded eight-sample stream.

Take JSON when the transport already compresses — WebSocket permessage-deflate, HTTP Content-Encoding, a log pipeline that gzips. The real cost is then about 40%, and you get a payload you can read in a log, a 2 KB bundle, and nothing to install. Take protobuf when bytes on the wire are the binding constraint and you cannot rely on transport compression.

Install

npm install @observertc/samples-json-codec

ESM and CommonJS builds with full type declarations. Node ≥ 20 and any modern browser — nothing here touches Buffer or any other Node built-in.

Usage

Encoding, on the client

import { ClientSampleEncoder } from '@observertc/samples-json-codec';

const encoder = new ClientSampleEncoder({ clientId });

monitor.on('sample-created', ({ sample }) => {
    websocket.send(encoder.encodeToJson(sample));   // string
});

encode(sample) returns the delta as an object instead, for when you want to batch several, wrap them in an envelope, or look at what changed.

Decoding, on the server

import { ClientSampleDecoder } from '@observertc/samples-json-codec';

const decoder = new ClientSampleDecoder();

websocket.on('message', (text) => {
    observer.accept(decoder.decodeJson(String(text)));
});

One decoder per connection, and it must see that connection’s messages in order. The returned sample is yours — the decoder keeps its own copy, so a pipeline that annotates samples in place cannot corrupt the stream.

Both halves at once

import { createClientSampleCodec } from '@observertc/samples-json-codec';

const codec = createClientSampleCodec({ clientId });
const sample = codec.decoder.decode(codec.encoder.encode(input));

Streams are ordered and stateful

A delta says “jitter is now 12” and says nothing about the forty fields that did not move, so a decoder that has not seen the earlier messages cannot rebuild the sample — and this package says so, with a STREAM_DESYNC error, rather than handing back an object quietly missing its trackIdentifier.

  • One decoder per encoder. They are a pair.
  • The transport must be ordered and lossless. A WebSocket or an HTTP/2 stream is fine; a datagram transport is not, unless you add your own ordering.
  • reset() is a keyframe. Call it on both halves at the same point in the stream — after a reconnect, on a schedule, or whenever a new receiver needs to be able to join.
encoder.reset();
const keyframe = encoder.encode(sample);
new ClientSampleDecoder().decode(keyframe);   // works

clientId is the exception: it rides on every message, so a receiver can attribute one without routing state of its own.

A field that stops being reported keeps its last value

There is deliberately no way to say “this field is gone” — the same as the protobuf codec. The decoder returns the running forward-fill of the stream: if sample 4 carries jitter and sample 5 does not, decoded sample 5 still has sample 4’s jitter.

That is almost always what you want from getStats() output, where a field missing from one tick means the browser did not report it rather than that it became meaningless.

Collections work the other way round: the newest sample defines membership. An RTP stream, codec or candidate pair that stops appearing has left the call, and its state is dropped on both sides — so if it comes back, it is re-sent in full. This is why a collection appears in every delta, with each entry carrying at least its key, even when nothing inside it changed.

Errors

Everything this package throws is a JsonCodecError with a code and a context.path pointing at the offending value. The codes match the protobuf codec’s, so error handling ports across unchanged:

codeMeaning
MALFORMED_INPUTThe input is not a well-formed delta
STREAM_DESYNCThe decoder has not seen enough of the stream to rebuild this sample
INVALID_VALUEA value cannot be represented — a missing collection key, a NaN
INVALID_OPTIONThe codec was configured with something it cannot honour
const result = decoder.tryDecodeJson(text);
if (!result.ok) {
    metrics.increment('codec.decode_failed', { code: result.error.code });
    return;
}

NaN and Infinity are rejected, not encoded

JSON.stringify turns them into null without complaint, which would silently corrupt the stream. This holds at any depth — a payload or an attachments object may nest freely, and a non-finite number anywhere inside one is reported with the path that reached it. A bigint is rejected on the same grounds; a value carrying its own toJSON, such as a Date, is left to serialise itself.

The package writes nothing to console on its own. Pass a logger if you want to hear about recoverable oddities.

API

ExportWhat it is
ClientSampleEncoderencode, encodeToJson, reset
ClientSampleDecoderdecode, decodeJson, tryDecode, tryDecodeJson, reset
createClientSampleCodecBoth halves from one options object
JsonCodecErrorEvery failure, with code and context
ClientSampleDeltaThe wire type — a deep-partial ClientSample
ClientSample and friendsThe plain sample types, re-exported
schemaVersionWhat this build speaks

How it works

Two pure functions — a diff and a merge that are exact inverses — plus one previous object per stream. That is the entire codec, in under 300 lines.

The encoder advances its state by applying the delta it just produced, not by keeping the sample it was given. So whatever the encoder believes is, by construction, exactly what the decoder will believe.

Because JSON is self-describing, there is no field table and no schema at runtime. The only configuration is which arrays are keyed collections and by what field, which are event streams, and which objects are opaque — so a field added to the schema needs no change to the codec.

Reviewing a wire change

The golden fixture pins the exact deltas for a recorded stream. Because the format is plain JSON, a diff there shows precisely which field started or stopped being sent — a wire change is impossible to make by accident and easy to review on purpose.

Resources