Protobuf Codec

@observertc/samples-protobuf-codec encodes and decodes ClientSample objects over protobuf. It is a delta codec: each message on the wire carries the difference between one sample and the one before it, because a WebRTC stats sample is mostly identifiers, codec parameters and slow-moving counters that repeat verbatim every tick.

This replaces samples-encoder and samples-decoder

@observertc/samples-encoder and @observertc/samples-decoder were the same codec split across two published artefacts. They are deprecated and removed from the schema repository: they stopped at 3.3.0 and will not be regenerated, versioned or published again.

Version 3.3.0 stays installable from npm — removing the sources is not an unpublish — and the wire format is unchanged, so either package still interoperates with this one and the two ends of a stream can migrate independently. See migrating from the encoder / decoder pair.

Install

npm install @observertc/samples-protobuf-codec

Ships both ESM and CommonJS builds with full type declarations. Node ≥ 20, and any modern browser — nothing here touches Buffer or other Node built-ins, so it runs in the browser that produces the samples.

It is generated in lockstep with the schema, so the package version is the schema version it speaks. schemaVersion is exported for asserting that at runtime.

Usage

Encoding, on the client

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

const encoder = new ClientSampleEncoder({ clientId });

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

For a text-only transport there is encodeToBase64(sample).

Decoding, on the server

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

const decoder = new ClientSampleDecoder();

websocket.on('message', (bytes) => {
    const sample = decoder.decode(bytes);        // a complete ClientSample
    observer.accept(sample);
});

One decoder per connection, and it must see that connection’s messages in order.

Both halves at once

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

const codec = createClientSampleCodec({ clientId, identifiers: { clientId: 'uuid' } });
const sample = codec.decoder.decode(codec.encoder.encode(input));

Building both from one options object is the point: the encoder and the decoder have to agree about identifier packing, and the failure mode when they do not is mojibake rather than an error.

Streams are ordered and stateful

Message 5 says “jitter is now 12” and says nothing at all about the forty fields that did not move. A decoder that has not seen messages 1–4 therefore cannot reconstruct sample 5 — and this package tells you 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 one exception: it rides on every message, so a receiver can always attribute a message without keeping routing state of its own.

A field that stops being reported keeps its last value

A delta message says what changed. It has no way to say “this field is gone”, so 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. It does mean the decoder’s output is “everything known about this client so far”, not “exactly the object the encoder was handed”.

Collection membership works the other way round: the newest sample defines it. An RTP stream, codec or candidate pair that stops appearing is understood to have left the call, and its state is dropped on both sides — so if it comes back, it is re-sent in full.

Identifiers: utf8 or uuid

Identifier fields (callId, clientId, peerConnectionId, trackId / trackIdentifier) travel as bytes. If yours really are UUIDs, packing them into their 16 significant bytes halves them:

const options = {
    clientId,
    identifiers: {
        callId: 'uuid',
        clientId: 'uuid',
        peerConnectionId: 'uuid',
        trackId: 'uuid',
    },
} satisfies EncoderOptions;

The default is utf8 for all of them, because ObserveRTC does not require callers to use UUIDs. A value configured as uuid that is not a UUID is an INVALID_VALUE error at encode time, not a silent fallback — which is the fix for the old *IsUuid boolean pair, where a mismatch produced mangled ids and no error at all.

Errors

Everything this package throws is a ProtobufCodecError with a code and a context.path pointing at the offending value:

codeMeaning
MALFORMED_INPUTThe bytes are not a ClientSample protobuf message
STREAM_DESYNCThe decoder has not seen enough of the stream to rebuild this sample
INVALID_VALUEA value could not be converted between its plain and its wire form
INVALID_OPTIONThe codec was configured with something it cannot honour
const result = decoder.tryDecode(bytes);
if (!result.ok) {
    metrics.increment('codec.decode_failed', { code: result.error.code });
    return;
}

The package writes nothing to console on its own. Pass a logger (anything with debug/info/warn/error) if you want to hear about recoverable oddities.

A non-finite number anywhere inside a payload or attachments is rejected with INVALID_VALUE and the path that reached it, rather than being silently turned into null by JSON.stringify — this holds at any nesting depth. A bigint is rejected the same way, and a cycle is reported as MALFORMED_INPUT rather than by exhausting the stack.

API

ExportWhat it is
ClientSampleEncoderencode, encodeToBase64, encodeToMessage, reset
ClientSampleDecoderdecode, decodeBase64, decodeFromMessage, tryDecode, reset
createClientSampleCodecBoth halves from one options object
ProtobufCodecErrorEvery failure, with code and context
ClientSample and friendsThe plain sample types, re-exported
protobufThe generated protobuf bindings, namespaced
schemaVersion, PROTO_PACKAGEWhat this build speaks

encodeToMessage / decodeFromMessage work in protobuf messages rather than bytes, for when the sample is going straight into another protobuf structure — or when you want to look at what a delta actually contains.

How it works

The codec keeps one small state machine per live thing in the call — the client, each peer connection, each RTP stream, each ICE candidate pair — holding the last value seen for every field. Encoding walks that tree and writes down only what moved; decoding walks the same tree and lays the arriving values back over what it already had.

Two consequences worth knowing:

  • Collections are keyed, not positional. Peer connections match on peerConnectionId, RTP streams on ssrc, everything else on id. The key is repeated on every message so entries stay matchable.
  • The field mapping is read from the protobuf descriptor at runtime. Which fields exist, which are 64-bit, which are bytes, which nest — all of it comes off the descriptor rather than from hand-written per-field code, so a field added upstream is carried by the codec without a line of new code.

Wire compatibility

The .proto is the all-optional variant of ClientSample, where explicit presence is what carries “this field changed”. Field numbers are derived from the Avro schema and are part of the contract.

The test suite pins the actual bytes for two recorded streams. A change that alters them is a wire break and needs a schema version bump — a round-trip test would not notice, because encode and decode move together.

Choosing between the two codecs

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

Same delta semantics, same error codes, same API shape — you can swap one for the other without changing anything downstream. Take protobuf when bytes on the wire are the binding constraint and you cannot rely on transport compression.

Migrating from samples-encoder / samples-decoder

OldNew
Two packages, one per directionOne package, both directions
new ClientSampleEncoder(clientId, settings)new ClientSampleEncoder({ clientId, ...options })
encodeToBytes(sample)encode(sample)
decodeFromBytes(bytes)decode(bytes) — or tryDecode(bytes) for the non-throwing form
decodeFromBase64 / encodeToBase64decodeBase64 / encodeToBase64
{ clientIdIsUuid: true, callIdIsUuid: true, … }{ identifiers: { clientId: 'uuid', callId: 'uuid', … } }
A decoder joining late returned a half-built sampleSTREAM_DESYNC
Failures returned undefinedProtobufCodecError with a code and a context.path, or tryDecode()

Behaviours the old pair got wrong, corrected here:

  • false could never be transmitted. The boolean encoder skipped any falsy value, so active, nominated and powerEfficientDecoder could go true but never come back. An empty string had the same problem.
  • Two entries of the same type in one sample’s clientEvents lost the second one’s type — all events shared one stateful field encoder.
  • ClientMetaData.timestamp was dropped entirely on encode, and its peerConnectionId / trackId ignored the uuid settings.
  • codecId, mid and transportId used a one-time-pass encoder, so a value that legitimately changed mid-call was never re-sent. They are ordinary delta fields now.
  • attachments was compared by reference, so an object rebuilt with identical content was re-sent on every sample. It is compared by value now.

Resources