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 objectRecord<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 →