Schema 3.7.0
Wire format: unchanged
Both codecs’ golden fixtures are byte-identical to 3.6.0 apart from the recorded version string. A 3.6.0 decoder reads a 3.7.0 stream and vice versa; only the TypeScript type moved.
Changed — payloads may nest
ClientEvent.payload, ClientIssue.payload, ClientMetaData.payload and ExtensionStat.payload
changed from a map of primitives to a free-form JSON object — Record<string, unknown> in the
generated TypeScript, the same type attachments has had since it stopped being a string.
A payload value may now be an object, an array, or any nesting of the two:
monitor.addEvent({
type: 'DEVICE_SNAPSHOT',
payload: {
device: { os: { name: 'macOS', version: '15.2' }, cpu: { cores: 10 } },
permissions: ['camera', 'microphone'],
},
});Before this, a caller with structured context had to flatten it into dotted keys
('device.os.name') or stringify it into one field and parse it back on the far side.
Why the wire did not have to move
- On the protobuf wire a payload was already a JSON
string— proto3 can express neither the old union-valued map nor the new one — so nesting costs it nothing. - In the JSON codec a payload is an opaque object, written whole whenever it changes and never carried forward. That is what made nesting free there too: diffing into a payload would need a way to say “this key was removed”, which the format deliberately does not have.
The Avro description
In Avro the map’s values became AnyValue, a record that refers to itself through an objectValue
map and an arrayValue array — the shape protobuf’s Struct / Value uses, and the only way Avro
can describe unbounded nesting, since a union cannot contain another union and only a named type
can recurse.
Treat it as documentation of what may appear rather than as an encoding. Avro tags its union
branches in JSON, so the .avsc has not described the codecs’ actual bytes since payloads stopped
being strings in 3.5.0.
Upgrading
Reading a payload value now needs narrowing. payload.role was boolean | string | number and
is now unknown:
// before
const role = payload.role;
// after
const role = typeof payload.role === 'string' ? payload.role : String(payload.role);Writers need no change — everything that was valid before still is.
Fixed — a non-finite number no longer becomes null in silence
Both codecs copied opaque values with JSON.parse(JSON.stringify(v)), which turns NaN and
Infinity into null without complaint — exactly the corruption the top-level finite check has
always rejected, but it only ever saw the top level of a record.
With payloads able to nest, a NaN several levels down was newly easy to reach, so the encode path
now walks an opaque value before copying it and reports the path that was wrong
(INVALID_VALUE). A bigint, which previously escaped as a bare TypeError from inside the codec,
is rejected the same way, and a cycle is reported as MALFORMED_INPUT rather than by exhausting the
stack.
Both codecs use the same codes for the same inputs. Values JSON.stringify drops harmlessly —
functions, symbols, undefined — are still dropped, and a value with its own toJSON, such as a
Date, still serialises itself.
Internal — scoped field-type overrides
TypeScript field-type overrides in the generator may now be scoped to one record, as Record.field,
and a scoped key beats a bare one.
The bare payload entry that gives the four client payloads their opaque type would otherwise also
have caught SfuExtensionStats.payload, which is a genuine JSON string on the SFU wire; it keeps
its string type through ['SfuExtensionStats.payload', 'string']. Field names are not unique
across the schema set, so the mechanism now says which one it means.
← Back to version history · 3.6.0 →