From 9b709dc6952e8c479eb96594af71122deea1972e Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:51:24 -0700 Subject: [PATCH 01/11] Schema metadata support --- src/api/SignalClient.ts | 12 +- src/index.ts | 5 + src/room/Room.ts | 8 +- .../outgoing/OutgoingDataTrackManager.ts | 4 + src/room/data-track/outgoing/types.ts | 15 +- src/room/data-track/schema.ts | 197 ++++++++++++++++++ src/room/data-track/types.ts | 23 ++ 7 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 src/room/data-track/schema.ts diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index eb70fe30d3..b092ed4efa 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -56,7 +56,7 @@ import { } from '@livekit/protocol'; import log, { LoggerNames, getLogger } from '../logger'; import type { DataTrackHandle } from '../room/data-track/handle'; -import { type DataTrackSid } from '../room/data-track/types'; +import { DataTrackFrameEncoding, DataTrackSchemaId, type DataTrackSid } from '../room/data-track/types'; import { ConnectionError } from '../room/errors'; import CriticalTimers from '../room/timers'; import type { LoggerOptions } from '../room/types'; @@ -744,13 +744,21 @@ export class SignalClient { }); } - sendPublishDataTrackRequest(handle: DataTrackHandle, name: string, usesE2ee: boolean) { + sendPublishDataTrackRequest( + handle: DataTrackHandle, + name: string, + usesE2ee: boolean, + schema?: DataTrackSchemaId, + frameEncoding?: DataTrackFrameEncoding, + ) { return this.sendRequest({ case: 'publishDataTrackRequest', value: new PublishDataTrackRequest({ pubHandle: handle, name: name, encryption: usesE2ee ? Encryption_Type.GCM : Encryption_Type.NONE, + schema: schema ? DataTrackSchemaId.toProtobuf(schema) : undefined, + frameEncoding: frameEncoding ? DataTrackFrameEncoding.toProtobuf(frameEncoding) : undefined, }), }); } diff --git a/src/index.ts b/src/index.ts index be7a70b0f3..9c31324f2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -180,6 +180,11 @@ export type { DataTrackSubscribeOptions, RemoteDataTrackPipelineOptions, }; +export type { + DataTrackSchemaId, + DataTrackSchemaEncoding, + DataTrackFrameEncoding, +} from './room/data-track/types'; export { DataTrackPacket, type DataTrackPacketHeader } from './room/data-track/packet'; export { type DataTrackExtensions, diff --git a/src/room/Room.ts b/src/room/Room.ts index 05ef4aa0f7..2277afab2a 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -305,7 +305,13 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.outgoingDataTrackManager = new OutgoingDataTrackManager({ e2eeManager: this.e2eeManager }); this.outgoingDataTrackManager .on('sfuPublishRequest', (event) => { - this.engine.client.sendPublishDataTrackRequest(event.handle, event.name, event.usesE2ee); + this.engine.client.sendPublishDataTrackRequest( + event.handle, + event.name, + event.usesE2ee, + event.schema, + event.frameEncoding, + ); }) .on('sfuUnpublishRequest', (event) => { this.engine.client.sendUnPublishDataTrackRequest(event.handle); diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts index 19a627e8b7..3df5939e8b 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts @@ -263,6 +263,8 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => handle, name: options.name, usesE2ee: this.e2eeManager !== null, + schema: options.schema, + frameEncoding: options.frameEncoding, }); await descriptor.completionFuture.promise; @@ -397,6 +399,8 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => handle: descriptor.info.pubHandle, name: descriptor.info.name, usesE2ee: descriptor.info.usesE2ee, + schema: descriptor.info.schema, + frameEncoding: descriptor.info.frameEncoding, }); } } diff --git a/src/room/data-track/outgoing/types.ts b/src/room/data-track/outgoing/types.ts index cee0dc9768..d8fcfc25e3 100644 --- a/src/room/data-track/outgoing/types.ts +++ b/src/room/data-track/outgoing/types.ts @@ -1,12 +1,23 @@ import type { NonSharedUint8Array } from '../../../type-polyfills/non-shared-typed-arrays'; import type LocalDataTrack from '../LocalDataTrack'; import { type DataTrackHandle } from '../handle'; -import { type DataTrackInfo, type DataTrackSid } from '../types'; +import { + type DataTrackFrameEncoding, + type DataTrackInfo, + type DataTrackSchemaId, + type DataTrackSid, +} from '../types'; import { type DataTrackPublishError, type DataTrackPublishErrorReason } from './errors'; /** Options for publishing a data track. */ export type DataTrackOptions = { name: string; + + /** Schema describing frames sent on the track. */ + schema?: DataTrackSchemaId; + + /** Encoding of frames sent on the track. */ + frameEncoding?: DataTrackFrameEncoding; }; /** Encodes whether a data track publish request to the SFU has been successful or not. */ @@ -27,6 +38,8 @@ export type EventSfuPublishRequest = { handle: DataTrackHandle; name: string; usesE2ee: boolean; + schema?: DataTrackSchemaId; + frameEncoding?: DataTrackFrameEncoding; }; /** Request sent to the SFU to unpublish a track. */ diff --git a/src/room/data-track/schema.ts b/src/room/data-track/schema.ts new file mode 100644 index 0000000000..8b9f5b590d --- /dev/null +++ b/src/room/data-track/schema.ts @@ -0,0 +1,197 @@ +import { + DataTrackFrameEncoding as ProtocolDataTrackFrameEncoding, + DataTrackSchemaEncoding as ProtocolDataTrackSchemaEncoding, + DataTrackSchemaId as ProtocolDataTrackSchemaId, + DataTrackFrameEncoding_WellKnownFrameEncoding as ProtocolWellKnownFrameEncoding, + DataTrackSchemaEncoding_WellKnownSchemaEncoding as ProtocolWellKnownSchemaEncoding, +} from '@livekit/protocol'; + +/** + * Encoding used to interpret a data track schema definition. + * + * Identifies the interface definition language the schema is written in (e.g. a + * `.proto` file for `'protobuf'`). This in turn dictates the wire format of the + * frames the schema describes, captured by {@link DataTrackFrameEncoding}. + * + * The well-known encodings mirror the schema encodings from the MCAP spec: + * https://mcap.dev/spec/registry#schema-encodings. Use `{ custom }` for an + * application-specific encoding not enumerated here; prefer a well-known encoding + * where possible. The identifier must be non-empty and no longer than 32 characters. + * + * `'other'` is only produced when receiving a well-known encoding introduced after + * this SDK version; it is not meant to be sent. + */ +export type DataTrackSchemaEncoding = + /** Protocol Buffer IDL, describes `'protobuf'` encoded frames. */ + | 'protobuf' + /** FlatBuffer IDL, describes `'flatbuffer'` encoded frames. */ + | 'flatbuffer' + /** ROS 1 Message, describes `'ros1'` encoded frames. */ + | 'ros1Msg' + /** ROS 2 Message, describes `'cdr'` encoded frames. */ + | 'ros2Msg' + /** ROS 2 IDL, describes `'cdr'` encoded frames. */ + | 'ros2Idl' + /** OMG IDL, describes `'cdr'` encoded frames. */ + | 'omgIdl' + /** JSON Schema, describes `'json'` encoded frames. */ + | 'jsonSchema' + /** Another well-known encoding not known to this client version. */ + | 'other' + /** An application-specific encoding identified by the contained string. */ + | { custom: string }; + +/** + * Encoding used for frames pushed on a data track. + * + * The serialization format of the frame bytes (e.g. `'protobuf'`); the structure + * of those bytes is described by a schema, see {@link DataTrackSchemaEncoding}. + * + * Use `{ custom }` for an application-specific encoding not enumerated here; prefer + * a well-known encoding where possible. The identifier must be non-empty and no + * longer than 32 characters. + * + * `'other'` is only produced when receiving a well-known encoding introduced after + * this SDK version; it is not meant to be sent. + */ +export type DataTrackFrameEncoding = + /** ROS 1, must be described by a `'ros1Msg'` schema. */ + | 'ros1' + /** CDR, must be described by a `'ros2Msg'`, `'ros2Idl'`, or `'omgIdl'` schema. */ + | 'cdr' + /** Protocol Buffer, must be described by a `'protobuf'` schema. */ + | 'protobuf' + /** FlatBuffer, must be described by a `'flatbuffer'` schema. */ + | 'flatbuffer' + /** CBOR, self-describing. */ + | 'cbor' + /** MessagePack, self-describing. */ + | 'msgpack' + /** JSON, self-describing or described by a `'jsonSchema'` schema. */ + | 'json' + /** Another well-known encoding not known to this client version. */ + | 'other' + /** An application-specific encoding identified by the contained string. */ + | { custom: string }; + +/** + * Identifier for a data track schema. + * + * A compound identifier with two components: {@link name} and {@link encoding}. + * + * Two IDs are equal only if both components match; the same name with a different + * encoding refers to a distinct schema. + */ +export type DataTrackSchemaId = { + /** Name component of the identifier. Must be non-empty and no longer than 256 characters. */ + name: string; + /** Encoding component of the identifier. */ + encoding: DataTrackSchemaEncoding; +}; + +const SCHEMA_ENCODING_TO_WELL_KNOWN: Record = { + protobuf: ProtocolWellKnownSchemaEncoding.PROTOBUF, + flatbuffer: ProtocolWellKnownSchemaEncoding.FLATBUFFER, + ros1Msg: ProtocolWellKnownSchemaEncoding.ROS1_MSG, + ros2Msg: ProtocolWellKnownSchemaEncoding.ROS2_MSG, + ros2Idl: ProtocolWellKnownSchemaEncoding.ROS2_IDL, + omgIdl: ProtocolWellKnownSchemaEncoding.OMG_IDL, + jsonSchema: ProtocolWellKnownSchemaEncoding.JSON_SCHEMA, +}; + +const WELL_KNOWN_TO_SCHEMA_ENCODING: Partial< + Record +> = { + [ProtocolWellKnownSchemaEncoding.PROTOBUF]: 'protobuf', + [ProtocolWellKnownSchemaEncoding.FLATBUFFER]: 'flatbuffer', + [ProtocolWellKnownSchemaEncoding.ROS1_MSG]: 'ros1Msg', + [ProtocolWellKnownSchemaEncoding.ROS2_MSG]: 'ros2Msg', + [ProtocolWellKnownSchemaEncoding.ROS2_IDL]: 'ros2Idl', + [ProtocolWellKnownSchemaEncoding.OMG_IDL]: 'omgIdl', + [ProtocolWellKnownSchemaEncoding.JSON_SCHEMA]: 'jsonSchema', +}; + +const FRAME_ENCODING_TO_WELL_KNOWN: Record = { + ros1: ProtocolWellKnownFrameEncoding.ROS1, + cdr: ProtocolWellKnownFrameEncoding.CDR, + protobuf: ProtocolWellKnownFrameEncoding.PROTOBUF, + flatbuffer: ProtocolWellKnownFrameEncoding.FLATBUFFER, + cbor: ProtocolWellKnownFrameEncoding.CBOR, + msgpack: ProtocolWellKnownFrameEncoding.MSGPACK, + json: ProtocolWellKnownFrameEncoding.JSON, +}; + +const WELL_KNOWN_TO_FRAME_ENCODING: Partial< + Record +> = { + [ProtocolWellKnownFrameEncoding.ROS1]: 'ros1', + [ProtocolWellKnownFrameEncoding.CDR]: 'cdr', + [ProtocolWellKnownFrameEncoding.PROTOBUF]: 'protobuf', + [ProtocolWellKnownFrameEncoding.FLATBUFFER]: 'flatbuffer', + [ProtocolWellKnownFrameEncoding.CBOR]: 'cbor', + [ProtocolWellKnownFrameEncoding.MSGPACK]: 'msgpack', + [ProtocolWellKnownFrameEncoding.JSON]: 'json', +}; + +export const DataTrackSchemaEncoding = { + from(protocol: ProtocolDataTrackSchemaEncoding): DataTrackSchemaEncoding { + switch (protocol.value.case) { + case 'wellKnown': + // Maps unspecified or a value introduced after this client version to 'other'. + return WELL_KNOWN_TO_SCHEMA_ENCODING[protocol.value.value] ?? 'other'; + case 'custom': + return { custom: protocol.value.value }; + default: + return 'other'; + } + }, + toProtobuf(encoding: DataTrackSchemaEncoding): ProtocolDataTrackSchemaEncoding { + if (typeof encoding === 'object') { + return new ProtocolDataTrackSchemaEncoding({ + value: { case: 'custom', value: encoding.custom }, + }); + } + const wellKnown = + SCHEMA_ENCODING_TO_WELL_KNOWN[encoding] ?? ProtocolWellKnownSchemaEncoding.UNSPECIFIED; + return new ProtocolDataTrackSchemaEncoding({ value: { case: 'wellKnown', value: wellKnown } }); + }, +}; + +export const DataTrackFrameEncoding = { + from(protocol: ProtocolDataTrackFrameEncoding): DataTrackFrameEncoding { + switch (protocol.value.case) { + case 'wellKnown': + // Maps unspecified or a value introduced after this client version to 'other'. + return WELL_KNOWN_TO_FRAME_ENCODING[protocol.value.value] ?? 'other'; + case 'custom': + return { custom: protocol.value.value }; + default: + return 'other'; + } + }, + toProtobuf(encoding: DataTrackFrameEncoding): ProtocolDataTrackFrameEncoding { + if (typeof encoding === 'object') { + return new ProtocolDataTrackFrameEncoding({ + value: { case: 'custom', value: encoding.custom }, + }); + } + const wellKnown = + FRAME_ENCODING_TO_WELL_KNOWN[encoding] ?? ProtocolWellKnownFrameEncoding.UNSPECIFIED; + return new ProtocolDataTrackFrameEncoding({ value: { case: 'wellKnown', value: wellKnown } }); + }, +}; + +export const DataTrackSchemaId = { + from(protocol: ProtocolDataTrackSchemaId): DataTrackSchemaId { + return { + name: protocol.name, + encoding: protocol.encoding ? DataTrackSchemaEncoding.from(protocol.encoding) : 'other', + }; + }, + toProtobuf(schemaId: DataTrackSchemaId): ProtocolDataTrackSchemaId { + return new ProtocolDataTrackSchemaId({ + name: schemaId.name, + encoding: DataTrackSchemaEncoding.toProtobuf(schemaId.encoding), + }); + }, +}; diff --git a/src/room/data-track/types.ts b/src/room/data-track/types.ts index 3c5e2a38f3..5ee9dec05e 100644 --- a/src/room/data-track/types.ts +++ b/src/room/data-track/types.ts @@ -1,5 +1,8 @@ import { Encryption_Type, DataTrackInfo as ProtocolDataTrackInfo } from '@livekit/protocol'; import { type DataTrackHandle } from './handle'; +import { DataTrackFrameEncoding, DataTrackSchemaId } from './schema'; + +export * from './schema'; export type DataTrackSid = string; @@ -9,6 +12,18 @@ export type DataTrackInfo = { pubHandle: DataTrackHandle; name: string; usesE2ee: boolean; + + /** Schema associated with frames sent on the track. + * + * Absent if the publisher did not associate a {@link DataTrackSchemaId} with the track. + */ + schema?: DataTrackSchemaId; + + /** Encoding of frames sent on the track. + * + * Absent if the publisher did not specify a {@link DataTrackFrameEncoding} for the track. + */ + frameEncoding?: DataTrackFrameEncoding; }; export type RemoteDataTrackPipelineOptions = { @@ -26,6 +41,10 @@ export const DataTrackInfo = { pubHandle: protocolInfo.pubHandle, name: protocolInfo.name, usesE2ee: protocolInfo.encryption !== Encryption_Type.NONE, + schema: protocolInfo.schema ? DataTrackSchemaId.from(protocolInfo.schema) : undefined, + frameEncoding: protocolInfo.frameEncoding + ? DataTrackFrameEncoding.from(protocolInfo.frameEncoding) + : undefined, }; }, toProtobuf(info: DataTrackInfo): ProtocolDataTrackInfo { @@ -34,6 +53,10 @@ export const DataTrackInfo = { pubHandle: info.pubHandle, name: info.name, encryption: info.usesE2ee ? Encryption_Type.GCM : Encryption_Type.NONE, + schema: info.schema ? DataTrackSchemaId.toProtobuf(info.schema) : undefined, + frameEncoding: info.frameEncoding + ? DataTrackFrameEncoding.toProtobuf(info.frameEncoding) + : undefined, }); }, }; From ba74fd3466a92ec6a60fb99b6f78601417a70a66 Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:51:33 -0700 Subject: [PATCH 02/11] Testing --- .../outgoing/OutgoingDataTrackManager.test.ts | 50 +++++++++ src/room/data-track/schema.test.ts | 106 ++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 src/room/data-track/schema.test.ts diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts index aac62db8c0..e42fdc019b 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts @@ -251,6 +251,56 @@ describe('DataTrackOutgoingManager', () => { expect(handle).not.toStrictEqual(handle2); }); + it.each([ + { + title: 'well-known encodings', + schema: { name: 'my_schema', encoding: 'jsonSchema' }, + frameEncoding: 'json', + }, + { + title: 'custom encodings', + schema: { name: 'my_schema', encoding: { custom: 'a' } }, + frameEncoding: { custom: 'b' }, + }, + ] as const)( + 'should forward schema and frame encoding on publish ($title)', + async ({ schema, frameEncoding }) => { + const manager = new OutgoingDataTrackManager(); + const managerEvents = subscribeToEvents(manager, [ + 'sfuPublishRequest', + ]); + + const localDataTrack = new LocalDataTrack({ name: 'test', schema, frameEncoding }, manager); + + // 1. Publish a data track with schema metadata + const publishRequestPromise = localDataTrack.publish(); + + // 2. The publish request sent to the SFU carries the schema and frame encoding + const sfuPublishEvent = await managerEvents.waitFor('sfuPublishRequest'); + expect(sfuPublishEvent.schema).toStrictEqual(schema); + expect(sfuPublishEvent.frameEncoding).toStrictEqual(frameEncoding); + const handle = sfuPublishEvent.handle; + + // 3. Respond as the SFU would, echoing the metadata back on the DataTrackInfo + manager.receivedSfuPublishResponse(handle, { + type: 'ok', + data: { + sid: 'bogus-sid', + pubHandle: handle, + name: 'test', + usesE2ee: false, + schema, + frameEncoding, + }, + }); + await publishRequestPromise; + + // 4. The metadata is reflected on the local track's info + expect(localDataTrack.info?.schema).toStrictEqual(schema); + expect(localDataTrack.info?.frameEncoding).toStrictEqual(frameEncoding); + }, + ); + it.each([ // Single packet payload case [ diff --git a/src/room/data-track/schema.test.ts b/src/room/data-track/schema.test.ts new file mode 100644 index 0000000000..eed79d85e5 --- /dev/null +++ b/src/room/data-track/schema.test.ts @@ -0,0 +1,106 @@ +import { + DataTrackFrameEncoding as ProtocolDataTrackFrameEncoding, + DataTrackSchemaEncoding as ProtocolDataTrackSchemaEncoding, + DataTrackSchemaId as ProtocolDataTrackSchemaId, + DataTrackFrameEncoding_WellKnownFrameEncoding as ProtocolWellKnownFrameEncoding, + DataTrackSchemaEncoding_WellKnownSchemaEncoding as ProtocolWellKnownSchemaEncoding, +} from '@livekit/protocol'; +import { describe, expect, it } from 'vitest'; +import { DataTrackFrameEncoding, DataTrackSchemaEncoding, DataTrackSchemaId } from './schema'; + +describe('DataTrackSchemaEncoding', () => { + const wellKnown: Array = [ + 'protobuf', + 'flatbuffer', + 'ros1Msg', + 'ros2Msg', + 'ros2Idl', + 'omgIdl', + 'jsonSchema', + ]; + + it.each(wellKnown)('round-trips well-known encoding %s', (encoding) => { + const protobuf = DataTrackSchemaEncoding.toProtobuf(encoding); + expect(protobuf.value.case).toEqual('wellKnown'); + expect(DataTrackSchemaEncoding.from(protobuf)).toEqual(encoding); + }); + + it('round-trips a custom encoding', () => { + const encoding: DataTrackSchemaEncoding = { custom: 'my_encoding' }; + const protobuf = DataTrackSchemaEncoding.toProtobuf(encoding); + expect(protobuf.value).toEqual({ case: 'custom', value: 'my_encoding' }); + expect(DataTrackSchemaEncoding.from(protobuf)).toEqual(encoding); + }); + + it('maps an unspecified well-known value to "other"', () => { + const protobuf = new ProtocolDataTrackSchemaEncoding({ + value: { case: 'wellKnown', value: ProtocolWellKnownSchemaEncoding.UNSPECIFIED }, + }); + expect(DataTrackSchemaEncoding.from(protobuf)).toEqual('other'); + }); + + it('maps a well-known value introduced after this version to "other"', () => { + const protobuf = new ProtocolDataTrackSchemaEncoding({ + value: { case: 'wellKnown', value: 999 as ProtocolWellKnownSchemaEncoding }, + }); + expect(DataTrackSchemaEncoding.from(protobuf)).toEqual('other'); + }); + + it('maps an absent oneof to "other"', () => { + expect(DataTrackSchemaEncoding.from(new ProtocolDataTrackSchemaEncoding())).toEqual('other'); + }); +}); + +describe('DataTrackFrameEncoding', () => { + const wellKnown: Array = [ + 'ros1', + 'cdr', + 'protobuf', + 'flatbuffer', + 'cbor', + 'msgpack', + 'json', + ]; + + it.each(wellKnown)('round-trips well-known encoding %s', (encoding) => { + const protobuf = DataTrackFrameEncoding.toProtobuf(encoding); + expect(protobuf.value.case).toEqual('wellKnown'); + expect(DataTrackFrameEncoding.from(protobuf)).toEqual(encoding); + }); + + it('round-trips a custom encoding', () => { + const encoding: DataTrackFrameEncoding = { custom: 'my_encoding' }; + const protobuf = DataTrackFrameEncoding.toProtobuf(encoding); + expect(protobuf.value).toEqual({ case: 'custom', value: 'my_encoding' }); + expect(DataTrackFrameEncoding.from(protobuf)).toEqual(encoding); + }); + + it('maps an unspecified well-known value to "other"', () => { + const protobuf = new ProtocolDataTrackFrameEncoding({ + value: { case: 'wellKnown', value: ProtocolWellKnownFrameEncoding.UNSPECIFIED }, + }); + expect(DataTrackFrameEncoding.from(protobuf)).toEqual('other'); + }); + + it('maps a well-known value introduced after this version to "other"', () => { + const protobuf = new ProtocolDataTrackFrameEncoding({ + value: { case: 'wellKnown', value: 999 as ProtocolWellKnownFrameEncoding }, + }); + expect(DataTrackFrameEncoding.from(protobuf)).toEqual('other'); + }); +}); + +describe('DataTrackSchemaId', () => { + it('round-trips name and encoding', () => { + const schemaId: DataTrackSchemaId = { name: 'rgb', encoding: 'protobuf' }; + const protobuf = DataTrackSchemaId.toProtobuf(schemaId); + expect(protobuf).toBeInstanceOf(ProtocolDataTrackSchemaId); + expect(protobuf.name).toEqual('rgb'); + expect(DataTrackSchemaId.from(protobuf)).toEqual(schemaId); + }); + + it('defaults encoding to "other" when the protobuf encoding is absent', () => { + const protobuf = new ProtocolDataTrackSchemaId({ name: 'rgb' }); + expect(DataTrackSchemaId.from(protobuf)).toEqual({ name: 'rgb', encoding: 'other' }); + }); +}); From cdf192b52bd16c3ab64f0b9ff1996c2f351ec9a7 Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:59:27 -0700 Subject: [PATCH 03/11] Format --- src/api/SignalClient.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/api/SignalClient.ts b/src/api/SignalClient.ts index b092ed4efa..746560f854 100644 --- a/src/api/SignalClient.ts +++ b/src/api/SignalClient.ts @@ -56,7 +56,11 @@ import { } from '@livekit/protocol'; import log, { LoggerNames, getLogger } from '../logger'; import type { DataTrackHandle } from '../room/data-track/handle'; -import { DataTrackFrameEncoding, DataTrackSchemaId, type DataTrackSid } from '../room/data-track/types'; +import { + DataTrackFrameEncoding, + DataTrackSchemaId, + type DataTrackSid, +} from '../room/data-track/types'; import { ConnectionError } from '../room/errors'; import CriticalTimers from '../room/timers'; import type { LoggerOptions } from '../room/types'; From 5087e90ad20c66a1ecb26b56dc025dd04520f55e Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:22:12 -0700 Subject: [PATCH 04/11] Use `toStrictEqual` for tests --- src/room/data-track/schema.test.ts | 34 ++++++++++++++++-------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/room/data-track/schema.test.ts b/src/room/data-track/schema.test.ts index eed79d85e5..db82538405 100644 --- a/src/room/data-track/schema.test.ts +++ b/src/room/data-track/schema.test.ts @@ -21,33 +21,35 @@ describe('DataTrackSchemaEncoding', () => { it.each(wellKnown)('round-trips well-known encoding %s', (encoding) => { const protobuf = DataTrackSchemaEncoding.toProtobuf(encoding); - expect(protobuf.value.case).toEqual('wellKnown'); - expect(DataTrackSchemaEncoding.from(protobuf)).toEqual(encoding); + expect(protobuf.value.case).toStrictEqual('wellKnown'); + expect(DataTrackSchemaEncoding.from(protobuf)).toStrictEqual(encoding); }); it('round-trips a custom encoding', () => { const encoding: DataTrackSchemaEncoding = { custom: 'my_encoding' }; const protobuf = DataTrackSchemaEncoding.toProtobuf(encoding); - expect(protobuf.value).toEqual({ case: 'custom', value: 'my_encoding' }); - expect(DataTrackSchemaEncoding.from(protobuf)).toEqual(encoding); + expect(protobuf.value).toStrictEqual({ case: 'custom', value: 'my_encoding' }); + expect(DataTrackSchemaEncoding.from(protobuf)).toStrictEqual(encoding); }); it('maps an unspecified well-known value to "other"', () => { const protobuf = new ProtocolDataTrackSchemaEncoding({ value: { case: 'wellKnown', value: ProtocolWellKnownSchemaEncoding.UNSPECIFIED }, }); - expect(DataTrackSchemaEncoding.from(protobuf)).toEqual('other'); + expect(DataTrackSchemaEncoding.from(protobuf)).toStrictEqual('other'); }); it('maps a well-known value introduced after this version to "other"', () => { const protobuf = new ProtocolDataTrackSchemaEncoding({ value: { case: 'wellKnown', value: 999 as ProtocolWellKnownSchemaEncoding }, }); - expect(DataTrackSchemaEncoding.from(protobuf)).toEqual('other'); + expect(DataTrackSchemaEncoding.from(protobuf)).toStrictEqual('other'); }); it('maps an absent oneof to "other"', () => { - expect(DataTrackSchemaEncoding.from(new ProtocolDataTrackSchemaEncoding())).toEqual('other'); + expect(DataTrackSchemaEncoding.from(new ProtocolDataTrackSchemaEncoding())).toStrictEqual( + 'other', + ); }); }); @@ -64,29 +66,29 @@ describe('DataTrackFrameEncoding', () => { it.each(wellKnown)('round-trips well-known encoding %s', (encoding) => { const protobuf = DataTrackFrameEncoding.toProtobuf(encoding); - expect(protobuf.value.case).toEqual('wellKnown'); - expect(DataTrackFrameEncoding.from(protobuf)).toEqual(encoding); + expect(protobuf.value.case).toStrictEqual('wellKnown'); + expect(DataTrackFrameEncoding.from(protobuf)).toStrictEqual(encoding); }); it('round-trips a custom encoding', () => { const encoding: DataTrackFrameEncoding = { custom: 'my_encoding' }; const protobuf = DataTrackFrameEncoding.toProtobuf(encoding); - expect(protobuf.value).toEqual({ case: 'custom', value: 'my_encoding' }); - expect(DataTrackFrameEncoding.from(protobuf)).toEqual(encoding); + expect(protobuf.value).toStrictEqual({ case: 'custom', value: 'my_encoding' }); + expect(DataTrackFrameEncoding.from(protobuf)).toStrictEqual(encoding); }); it('maps an unspecified well-known value to "other"', () => { const protobuf = new ProtocolDataTrackFrameEncoding({ value: { case: 'wellKnown', value: ProtocolWellKnownFrameEncoding.UNSPECIFIED }, }); - expect(DataTrackFrameEncoding.from(protobuf)).toEqual('other'); + expect(DataTrackFrameEncoding.from(protobuf)).toStrictEqual('other'); }); it('maps a well-known value introduced after this version to "other"', () => { const protobuf = new ProtocolDataTrackFrameEncoding({ value: { case: 'wellKnown', value: 999 as ProtocolWellKnownFrameEncoding }, }); - expect(DataTrackFrameEncoding.from(protobuf)).toEqual('other'); + expect(DataTrackFrameEncoding.from(protobuf)).toStrictEqual('other'); }); }); @@ -95,12 +97,12 @@ describe('DataTrackSchemaId', () => { const schemaId: DataTrackSchemaId = { name: 'rgb', encoding: 'protobuf' }; const protobuf = DataTrackSchemaId.toProtobuf(schemaId); expect(protobuf).toBeInstanceOf(ProtocolDataTrackSchemaId); - expect(protobuf.name).toEqual('rgb'); - expect(DataTrackSchemaId.from(protobuf)).toEqual(schemaId); + expect(protobuf.name).toStrictEqual('rgb'); + expect(DataTrackSchemaId.from(protobuf)).toStrictEqual(schemaId); }); it('defaults encoding to "other" when the protobuf encoding is absent', () => { const protobuf = new ProtocolDataTrackSchemaId({ name: 'rgb' }); - expect(DataTrackSchemaId.from(protobuf)).toEqual({ name: 'rgb', encoding: 'other' }); + expect(DataTrackSchemaId.from(protobuf)).toStrictEqual({ name: 'rgb', encoding: 'other' }); }); }); From 4857efca2be6efd05f843e6a110df45c3d8ea057 Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:54:44 -0700 Subject: [PATCH 05/11] Simplify enum mapping --- src/room/data-track/schema.ts | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/src/room/data-track/schema.ts b/src/room/data-track/schema.ts index 8b9f5b590d..f81d87a9fb 100644 --- a/src/room/data-track/schema.ts +++ b/src/room/data-track/schema.ts @@ -99,17 +99,9 @@ const SCHEMA_ENCODING_TO_WELL_KNOWN: Record -> = { - [ProtocolWellKnownSchemaEncoding.PROTOBUF]: 'protobuf', - [ProtocolWellKnownSchemaEncoding.FLATBUFFER]: 'flatbuffer', - [ProtocolWellKnownSchemaEncoding.ROS1_MSG]: 'ros1Msg', - [ProtocolWellKnownSchemaEncoding.ROS2_MSG]: 'ros2Msg', - [ProtocolWellKnownSchemaEncoding.ROS2_IDL]: 'ros2Idl', - [ProtocolWellKnownSchemaEncoding.OMG_IDL]: 'omgIdl', - [ProtocolWellKnownSchemaEncoding.JSON_SCHEMA]: 'jsonSchema', -}; +const WELL_KNOWN_TO_SCHEMA_ENCODING = Object.fromEntries( + Object.entries(SCHEMA_ENCODING_TO_WELL_KNOWN).map(([key, value]) => [value, key]), +) as Partial>; const FRAME_ENCODING_TO_WELL_KNOWN: Record = { ros1: ProtocolWellKnownFrameEncoding.ROS1, @@ -121,17 +113,9 @@ const FRAME_ENCODING_TO_WELL_KNOWN: Record -> = { - [ProtocolWellKnownFrameEncoding.ROS1]: 'ros1', - [ProtocolWellKnownFrameEncoding.CDR]: 'cdr', - [ProtocolWellKnownFrameEncoding.PROTOBUF]: 'protobuf', - [ProtocolWellKnownFrameEncoding.FLATBUFFER]: 'flatbuffer', - [ProtocolWellKnownFrameEncoding.CBOR]: 'cbor', - [ProtocolWellKnownFrameEncoding.MSGPACK]: 'msgpack', - [ProtocolWellKnownFrameEncoding.JSON]: 'json', -}; +const WELL_KNOWN_TO_FRAME_ENCODING = Object.fromEntries( + Object.entries(FRAME_ENCODING_TO_WELL_KNOWN).map(([key, value]) => [value, key]), +) as Partial>; export const DataTrackSchemaEncoding = { from(protocol: ProtocolDataTrackSchemaEncoding): DataTrackSchemaEncoding { From 13c9643d5b5e47a8a49f2afddc68f3e7be52cc27 Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:55:19 -0700 Subject: [PATCH 06/11] Don't use wildcard export Co-authored-by: Ryan Gaus --- src/room/data-track/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/room/data-track/types.ts b/src/room/data-track/types.ts index 5ee9dec05e..68e0418838 100644 --- a/src/room/data-track/types.ts +++ b/src/room/data-track/types.ts @@ -2,7 +2,7 @@ import { Encryption_Type, DataTrackInfo as ProtocolDataTrackInfo } from '@liveki import { type DataTrackHandle } from './handle'; import { DataTrackFrameEncoding, DataTrackSchemaId } from './schema'; -export * from './schema'; +export { DataTrackFrameEncoding, DataTrackSchemaEncoding, DataTrackSchemaId } from './schema'; export type DataTrackSid = string; From 6ca6f2ef86577b3403c3cb52e6b8bcc30d241b6c Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:57:53 -0700 Subject: [PATCH 07/11] Don't use Object.fromEntries --- src/room/data-track/schema.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/room/data-track/schema.ts b/src/room/data-track/schema.ts index f81d87a9fb..12c1af8f7a 100644 --- a/src/room/data-track/schema.ts +++ b/src/room/data-track/schema.ts @@ -99,9 +99,18 @@ const SCHEMA_ENCODING_TO_WELL_KNOWN: Record [value, key]), -) as Partial>; +// Note: not using Object.fromEntries as it requires ES2019. +function invert(mapping: Record): Partial> { + const inverted: Partial> = {}; + for (const [key, value] of Object.entries(mapping)) { + inverted[value] = key; + } + return inverted; +} + +const WELL_KNOWN_TO_SCHEMA_ENCODING = invert(SCHEMA_ENCODING_TO_WELL_KNOWN) as Partial< + Record +>; const FRAME_ENCODING_TO_WELL_KNOWN: Record = { ros1: ProtocolWellKnownFrameEncoding.ROS1, From 366682a091ec47f9e3db7e2680e7b04dc3551c0b Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:58:00 -0700 Subject: [PATCH 08/11] Validate schema metadata --- .../outgoing/OutgoingDataTrackManager.test.ts | 21 +++ .../outgoing/OutgoingDataTrackManager.ts | 7 + src/room/data-track/outgoing/errors.ts | 10 ++ src/room/data-track/schema.test.ts | 58 ++++++++- src/room/data-track/schema.ts | 122 +++++++++++++++++- 5 files changed, 214 insertions(+), 4 deletions(-) diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts index e42fdc019b..dec9782678 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts @@ -11,6 +11,7 @@ import RTCEngine from '../../RTCEngine'; import Room from '../../Room'; import { DataTrackHandle } from '../handle'; import { DataTrackPacket, FrameMarker } from '../packet'; +import { DataTrackSchemaError } from '../schema'; import OutgoingDataTrackManager, { type DataTrackOutgoingManagerCallbacks, Descriptor, @@ -132,6 +133,26 @@ describe('DataTrackOutgoingManager', () => { ); }); + it('should reject publishing when schema metadata is invalid', async () => { + const manager = new OutgoingDataTrackManager(); + const sfuPublishRequest = vi.fn(); + manager.on('sfuPublishRequest', sfuPublishRequest); + + // Providing a schema ID without a frame encoding is invalid. + const localDataTrack = new LocalDataTrack( + { name: 'test', schema: { name: 'my_schema', encoding: 'jsonSchema' } }, + manager, + ); + + await expect(localDataTrack.publish()).rejects.toStrictEqual( + DataTrackPublishError.invalidSchema(DataTrackSchemaError.missingFrameEncoding()), + ); + + // The invalid request must not be sent to the SFU. + expect(sfuPublishRequest).not.toHaveBeenCalled(); + expect(localDataTrack.isPublished()).toStrictEqual(false); + }); + it('should test track publishing (cancellation half way through)', async () => { const manager = new OutgoingDataTrackManager(); const managerEvents = subscribeToEvents(manager, [ diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts index 3df5939e8b..8ece48bd70 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts @@ -8,6 +8,7 @@ import { Future } from '../../utils'; import LocalDataTrack from '../LocalDataTrack'; import type { DataTrackFrameInternal } from '../frame'; import { DataTrackHandle, DataTrackHandleAllocator } from '../handle'; +import { type DataTrackSchemaError, validateSchemaMetadata } from '../schema'; import { type DataTrackInfo } from '../types'; import { DataTrackPublishError, @@ -215,6 +216,12 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () => options: DataTrackOptions, signal?: AbortSignal, ): Promise> { + try { + validateSchemaMetadata(options.frameEncoding, options.schema?.encoding); + } catch (error) { + throw DataTrackPublishError.invalidSchema(error as DataTrackSchemaError); + } + const handle = this.handleAllocator.get(); if (!handle) { throw DataTrackPublishError.limitReached(); diff --git a/src/room/data-track/outgoing/errors.ts b/src/room/data-track/outgoing/errors.ts index 568dd0b6a7..bb3d37b126 100644 --- a/src/room/data-track/outgoing/errors.ts +++ b/src/room/data-track/outgoing/errors.ts @@ -1,5 +1,6 @@ import { LivekitReasonedError } from '../../errors'; import { DataTrackPacketizerError } from '../packetizer'; +import type { DataTrackSchemaError } from '../schema'; export enum DataTrackPublishErrorReason { /** @@ -30,6 +31,9 @@ export enum DataTrackPublishErrorReason { /** There was an error publishing, but it was not something that could be sorted into a known * category. */ Unknown = 7, + + /** Schema metadata is invalid. */ + InvalidSchema = 8, } export class DataTrackPublishError< @@ -108,6 +112,12 @@ export class DataTrackPublishError< DataTrackPublishErrorReason.Cancelled, ); } + + static invalidSchema(cause: DataTrackSchemaError) { + return new DataTrackPublishError(cause.message, DataTrackPublishErrorReason.InvalidSchema, { + cause, + }); + } } export enum DataTrackPushFrameErrorReason { diff --git a/src/room/data-track/schema.test.ts b/src/room/data-track/schema.test.ts index db82538405..966245121e 100644 --- a/src/room/data-track/schema.test.ts +++ b/src/room/data-track/schema.test.ts @@ -6,7 +6,14 @@ import { DataTrackSchemaEncoding_WellKnownSchemaEncoding as ProtocolWellKnownSchemaEncoding, } from '@livekit/protocol'; import { describe, expect, it } from 'vitest'; -import { DataTrackFrameEncoding, DataTrackSchemaEncoding, DataTrackSchemaId } from './schema'; +import { + DataTrackFrameEncoding, + DataTrackSchemaEncoding, + DataTrackSchemaError, + DataTrackSchemaErrorReason, + DataTrackSchemaId, + validateSchemaMetadata, +} from './schema'; describe('DataTrackSchemaEncoding', () => { const wellKnown: Array = [ @@ -106,3 +113,52 @@ describe('DataTrackSchemaId', () => { expect(DataTrackSchemaId.from(protobuf)).toStrictEqual({ name: 'rgb', encoding: 'other' }); }); }); + +describe('validateSchemaMetadata', () => { + function expectSchemaError(reason: DataTrackSchemaErrorReason, fn: () => void) { + let thrown: unknown; + try { + fn(); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(DataTrackSchemaError); + expect((thrown as DataTrackSchemaError).reason).toStrictEqual(reason); + } + + it('accepts absent schema metadata', () => { + expect(() => validateSchemaMetadata(undefined, undefined)).not.toThrow(); + }); + + it('accepts a self-describing frame encoding without a schema', () => { + expect(() => validateSchemaMetadata('json', undefined)).not.toThrow(); + }); + + it('accepts compatible frame and schema encodings', () => { + expect(() => validateSchemaMetadata('cdr', 'ros2Idl')).not.toThrow(); + }); + + it('accepts custom encodings, which cannot be validated', () => { + expect(() => + validateSchemaMetadata({ custom: 'my-frame-encoding' }, { custom: 'my-schema-encoding' }), + ).not.toThrow(); + }); + + it('rejects a schema without a frame encoding', () => { + expectSchemaError(DataTrackSchemaErrorReason.MissingFrameEncoding, () => + validateSchemaMetadata(undefined, 'protobuf'), + ); + }); + + it('rejects a non-self-describing frame encoding without a schema', () => { + expectSchemaError(DataTrackSchemaErrorReason.MissingSchemaId, () => + validateSchemaMetadata('protobuf', undefined), + ); + }); + + it('rejects incompatible frame and schema encodings', () => { + expectSchemaError(DataTrackSchemaErrorReason.Incompatible, () => + validateSchemaMetadata('json', 'protobuf'), + ); + }); +}); diff --git a/src/room/data-track/schema.ts b/src/room/data-track/schema.ts index 12c1af8f7a..30fd414c0a 100644 --- a/src/room/data-track/schema.ts +++ b/src/room/data-track/schema.ts @@ -5,6 +5,8 @@ import { DataTrackFrameEncoding_WellKnownFrameEncoding as ProtocolWellKnownFrameEncoding, DataTrackSchemaEncoding_WellKnownSchemaEncoding as ProtocolWellKnownSchemaEncoding, } from '@livekit/protocol'; +import type { Throws } from '@livekit/throws-transformer/throws'; +import { LivekitReasonedError } from '../errors'; /** * Encoding used to interpret a data track schema definition. @@ -122,9 +124,21 @@ const FRAME_ENCODING_TO_WELL_KNOWN: Record [value, key]), -) as Partial>; +const WELL_KNOWN_TO_FRAME_ENCODING = invert(FRAME_ENCODING_TO_WELL_KNOWN) as Partial< + Record +>; + +/** Frame encodings that are self-describing (i.e. require no schema). */ +const SELF_DESCRIBING_FRAME_ENCODINGS: DataTrackFrameEncoding[] = ['cbor', 'msgpack', 'json']; + +/** Schema encodings capable of describing frames with each frame encoding. */ +const COMPATIBLE_SCHEMA_ENCODINGS: Record = { + ros1: ['ros1Msg'], + cdr: ['ros2Msg', 'ros2Idl', 'omgIdl'], + protobuf: ['protobuf'], + flatbuffer: ['flatbuffer'], + json: ['jsonSchema'], +}; export const DataTrackSchemaEncoding = { from(protocol: ProtocolDataTrackSchemaEncoding): DataTrackSchemaEncoding { @@ -172,6 +186,31 @@ export const DataTrackFrameEncoding = { FRAME_ENCODING_TO_WELL_KNOWN[encoding] ?? ProtocolWellKnownFrameEncoding.UNSPECIFIED; return new ProtocolDataTrackFrameEncoding({ value: { case: 'wellKnown', value: wellKnown } }); }, + /** + * Whether frames with this encoding are self-describing (i.e. require no schema). + * + * Returns `undefined` when this cannot be determined ('other' or a custom encoding). + */ + isSelfDescribing(encoding: DataTrackFrameEncoding): boolean | undefined { + if (typeof encoding === 'object' || encoding === 'other') { + return undefined; // Cannot be validated + } + return SELF_DESCRIBING_FRAME_ENCODINGS.includes(encoding); + }, + /** + * Whether frames with this encoding can be described by a schema with the given encoding. + * + * Returns `undefined` when this cannot be determined ('other' or a custom frame encoding). + */ + isDescribedBy( + encoding: DataTrackFrameEncoding, + schemaEncoding: DataTrackSchemaEncoding, + ): boolean | undefined { + if (typeof encoding === 'object' || encoding === 'other') { + return undefined; // Cannot be validated + } + return (COMPATIBLE_SCHEMA_ENCODINGS[encoding] ?? []).includes(schemaEncoding); + }, }; export const DataTrackSchemaId = { @@ -188,3 +227,80 @@ export const DataTrackSchemaId = { }); }, }; + +export enum DataTrackSchemaErrorReason { + /** Frame encoding is required when providing a schema ID. */ + MissingFrameEncoding = 0, + + /** Schema ID is required for a frame encoding that is not self-describing. */ + MissingSchemaId = 1, + + /** Specified schema and frame encodings are incompatible. */ + Incompatible = 2, +} + +export class DataTrackSchemaError< + Reason extends DataTrackSchemaErrorReason = DataTrackSchemaErrorReason, +> extends LivekitReasonedError { + readonly name = 'DataTrackSchemaError'; + + reason: Reason; + + reasonName: string; + + constructor(message: string, reason: Reason) { + super(23, message); + this.reason = reason; + this.reasonName = DataTrackSchemaErrorReason[reason]; + } + + static missingFrameEncoding() { + return new DataTrackSchemaError( + 'Frame encoding is required when providing schema ID', + DataTrackSchemaErrorReason.MissingFrameEncoding, + ); + } + + static missingSchemaId() { + return new DataTrackSchemaError( + 'Schema ID is required for frame encoding that is not self-describing', + DataTrackSchemaErrorReason.MissingSchemaId, + ); + } + + static incompatible() { + return new DataTrackSchemaError( + 'Specified schema and frame encodings are incompatible', + DataTrackSchemaErrorReason.Incompatible, + ); + } +} + +/** + * Validates that the given frame and schema encodings are compatible. + * + * Combinations involving 'other' or custom encodings cannot be validated and + * are accepted as-is. + * + * @internal + */ +export function validateSchemaMetadata( + frameEncoding: DataTrackFrameEncoding | undefined, + schemaEncoding: DataTrackSchemaEncoding | undefined, +): Throws { + if (frameEncoding === undefined) { + if (schemaEncoding !== undefined) { + throw DataTrackSchemaError.missingFrameEncoding(); + } + return; // Not using schema metadata + } + if (schemaEncoding === undefined) { + if (DataTrackFrameEncoding.isSelfDescribing(frameEncoding) === false) { + throw DataTrackSchemaError.missingSchemaId(); + } + return; + } + if (DataTrackFrameEncoding.isDescribedBy(frameEncoding, schemaEncoding) === false) { + throw DataTrackSchemaError.incompatible(); + } +} From 5bce345525cb14e8f5e67e960a7ab03fe72a53c1 Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:39:59 -0700 Subject: [PATCH 09/11] Preserve schema metadata in publish response --- src/room/Room.ts | 7 +------ .../data-track/outgoing/OutgoingDataTrackManager.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/room/Room.ts b/src/room/Room.ts index 2277afab2a..5f095947c2 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -693,12 +693,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) this.outgoingDataTrackManager.receivedSfuPublishResponse(event.info.pubHandle, { type: 'ok', - data: { - sid: event.info.sid, - pubHandle: event.info.pubHandle, - name: event.info.name, - usesE2ee: event.info.encryption !== Encryption_Type.NONE, - }, + data: DataTrackInfo.from(event.info), }); }) .on(EngineEvent.UnPublishDataTrackResponse, (event) => { diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts index dec9782678..51ee0eaf15 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts @@ -319,6 +319,15 @@ describe('DataTrackOutgoingManager', () => { // 4. The metadata is reflected on the local track's info expect(localDataTrack.info?.schema).toStrictEqual(schema); expect(localDataTrack.info?.frameEncoding).toStrictEqual(frameEncoding); + + // 5. Republishing after a full reconnect re-announces the track with the same metadata, + // since a track's schema is immutable even though it is assigned a new sid. + manager.sfuWillRepublishTracks(); + + const republishEvent = await managerEvents.waitFor('sfuPublishRequest'); + expect(republishEvent.handle).toStrictEqual(handle); + expect(republishEvent.schema).toStrictEqual(schema); + expect(republishEvent.frameEncoding).toStrictEqual(frameEncoding); }, ); From d544693b3569490c551b4591d673f9955dca58f2 Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:49:08 -0700 Subject: [PATCH 10/11] Update validation logic --- src/room/data-track/schema.test.ts | 16 +++++++++++++++ src/room/data-track/schema.ts | 33 ++++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/room/data-track/schema.test.ts b/src/room/data-track/schema.test.ts index 966245121e..4374a19dd9 100644 --- a/src/room/data-track/schema.test.ts +++ b/src/room/data-track/schema.test.ts @@ -144,6 +144,22 @@ describe('validateSchemaMetadata', () => { ).not.toThrow(); }); + it('accepts a custom schema encoding with a well-known frame encoding', () => { + expect(() => validateSchemaMetadata('json', { custom: 'my-schema-encoding' })).not.toThrow(); + }); + + it("rejects an 'other' schema encoding", () => { + expectSchemaError(DataTrackSchemaErrorReason.OtherEncoding, () => + validateSchemaMetadata('json', 'other'), + ); + }); + + it("rejects an 'other' frame encoding", () => { + expectSchemaError(DataTrackSchemaErrorReason.OtherEncoding, () => + validateSchemaMetadata('other', undefined), + ); + }); + it('rejects a schema without a frame encoding', () => { expectSchemaError(DataTrackSchemaErrorReason.MissingFrameEncoding, () => validateSchemaMetadata(undefined, 'protobuf'), diff --git a/src/room/data-track/schema.ts b/src/room/data-track/schema.ts index 30fd414c0a..4a233edcad 100644 --- a/src/room/data-track/schema.ts +++ b/src/room/data-track/schema.ts @@ -21,7 +21,7 @@ import { LivekitReasonedError } from '../errors'; * where possible. The identifier must be non-empty and no longer than 32 characters. * * `'other'` is only produced when receiving a well-known encoding introduced after - * this SDK version; it is not meant to be sent. + * this SDK version; it cannot be used when publishing. */ export type DataTrackSchemaEncoding = /** Protocol Buffer IDL, describes `'protobuf'` encoded frames. */ @@ -54,7 +54,7 @@ export type DataTrackSchemaEncoding = * longer than 32 characters. * * `'other'` is only produced when receiving a well-known encoding introduced after - * this SDK version; it is not meant to be sent. + * this SDK version; it cannot be used when publishing. */ export type DataTrackFrameEncoding = /** ROS 1, must be described by a `'ros1Msg'` schema. */ @@ -200,13 +200,19 @@ export const DataTrackFrameEncoding = { /** * Whether frames with this encoding can be described by a schema with the given encoding. * - * Returns `undefined` when this cannot be determined ('other' or a custom frame encoding). + * Returns `undefined` when this cannot be determined ('other' or a custom encoding + * on either side). */ isDescribedBy( encoding: DataTrackFrameEncoding, schemaEncoding: DataTrackSchemaEncoding, ): boolean | undefined { - if (typeof encoding === 'object' || encoding === 'other') { + if ( + typeof encoding === 'object' || + encoding === 'other' || + typeof schemaEncoding === 'object' || + schemaEncoding === 'other' + ) { return undefined; // Cannot be validated } return (COMPATIBLE_SCHEMA_ENCODINGS[encoding] ?? []).includes(schemaEncoding); @@ -237,6 +243,10 @@ export enum DataTrackSchemaErrorReason { /** Specified schema and frame encodings are incompatible. */ Incompatible = 2, + + /** The 'other' encoding represents an unrecognized encoding on received tracks + * and cannot be used when publishing. */ + OtherEncoding = 3, } export class DataTrackSchemaError< @@ -274,13 +284,21 @@ export class DataTrackSchemaError< DataTrackSchemaErrorReason.Incompatible, ); } + + static otherEncoding() { + return new DataTrackSchemaError( + "The 'other' encoding cannot be used when publishing", + DataTrackSchemaErrorReason.OtherEncoding, + ); + } } /** * Validates that the given frame and schema encodings are compatible. * - * Combinations involving 'other' or custom encodings cannot be validated and - * are accepted as-is. + * Combinations involving custom encodings cannot be validated and are accepted + * as-is. The 'other' encoding only represents unrecognized encodings on received + * tracks and is rejected. * * @internal */ @@ -288,6 +306,9 @@ export function validateSchemaMetadata( frameEncoding: DataTrackFrameEncoding | undefined, schemaEncoding: DataTrackSchemaEncoding | undefined, ): Throws { + if (frameEncoding === 'other' || schemaEncoding === 'other') { + throw DataTrackSchemaError.otherEncoding(); + } if (frameEncoding === undefined) { if (schemaEncoding !== undefined) { throw DataTrackSchemaError.missingFrameEncoding(); From cf53042e03211a4c76410c29ce1014d4ae783115 Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:24:34 -0700 Subject: [PATCH 11/11] Follow documented test convention Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../data-track/outgoing/OutgoingDataTrackManager.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts index 51ee0eaf15..db6874e6f4 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts @@ -135,8 +135,9 @@ describe('DataTrackOutgoingManager', () => { it('should reject publishing when schema metadata is invalid', async () => { const manager = new OutgoingDataTrackManager(); - const sfuPublishRequest = vi.fn(); - manager.on('sfuPublishRequest', sfuPublishRequest); + const managerEvents = subscribeToEvents(manager, [ + 'sfuPublishRequest', + ]); // Providing a schema ID without a frame encoding is invalid. const localDataTrack = new LocalDataTrack( @@ -149,7 +150,7 @@ describe('DataTrackOutgoingManager', () => { ); // The invalid request must not be sent to the SFU. - expect(sfuPublishRequest).not.toHaveBeenCalled(); + expect(managerEvents.areThereBufferedEvents('sfuPublishRequest')).toBe(false); expect(localDataTrack.isPublished()).toStrictEqual(false); });