@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.
npm install @observertc/samples-protobuf-codecShips 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.
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).
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.
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.
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.
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 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.
utf8 or uuidIdentifier 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.
Everything this package throws is a ProtobufCodecError with a code and a context.path pointing
at the offending value:
code | Meaning |
|---|---|
MALFORMED_INPUT | The bytes are not a ClientSample protobuf message |
STREAM_DESYNC | The decoder has not seen enough of the stream to rebuild this sample |
INVALID_VALUE | A value could not be converted between its plain and its wire form |
INVALID_OPTION | The 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.
| Export | What it is |
|---|---|
ClientSampleEncoder | encode, encodeToBase64, encodeToMessage, reset |
ClientSampleDecoder | decode, decodeBase64, decodeFromMessage, tryDecode, reset |
createClientSampleCodec | Both halves from one options object |
ProtobufCodecError | Every failure, with code and context |
ClientSample and friends | The plain sample types, re-exported |
protobuf | The generated protobuf bindings, namespaced |
schemaVersion, PROTO_PACKAGE | What 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.
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:
peerConnectionId, RTP
streams on ssrc, everything else on id. The key is repeated on every message so entries stay
matchable.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.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.
samples-protobuf-codec | samples-json-codec | |
|---|---|---|
| Runtime dependencies | @bufbuild/protobuf | none |
| Bundle, minified + gzip | ~29.8 KB | ~2.1 KB |
| Payload, raw | 1× | 2.73× |
| Payload, gzipped | 1× | 1.41× |
| Readable on the wire | no | yes |
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.
| Old | New |
|---|---|
| Two packages, one per direction | One 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 / encodeToBase64 | decodeBase64 / encodeToBase64 |
{ clientIdIsUuid: true, callIdIsUuid: true, … } | { identifiers: { clientId: 'uuid', callId: 'uuid', … } } |
| A decoder joining late returned a half-built sample | STREAM_DESYNC |
Failures returned undefined | ProtobufCodecError 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.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.samples-json-codec — the same codec over JSON