From f570a06be09a5f43e6b3acf3ecc38ef401a0e5ae Mon Sep 17 00:00:00 2001 From: lukasIO Date: Mon, 19 Oct 2026 14:33:19 +0200 Subject: [PATCH 1/2] Create data channels as negotiated in single PC mode (protocol 18) Port of livekit/rust-sdks#1527. Bumps the client protocol to 18 and creates the publisher data channels with fixed SCTP stream IDs (100/101/102) when the server supports it, falling back to in-band channels against older servers or in dual PC mode. Co-Authored-By: Claude Opus 5.5 --- .changeset/negotiated-data-channels.md | 5 +++ src/room/RTCEngine.test.ts | 34 ++++++++++++++++++ src/room/RTCEngine.ts | 31 +++++++++++++--- .../data-channel/DataChannelManager.test.ts | 36 +++++++++++++++++++ src/room/data-channel/DataChannelManager.ts | 29 +++++++++++++-- src/version.ts | 6 +++- 6 files changed, 132 insertions(+), 9 deletions(-) create mode 100644 .changeset/negotiated-data-channels.md diff --git a/.changeset/negotiated-data-channels.md b/.changeset/negotiated-data-channels.md new file mode 100644 index 0000000000..2318500bb9 --- /dev/null +++ b/.changeset/negotiated-data-channels.md @@ -0,0 +1,5 @@ +--- +'livekit-client': minor +--- + +Create the publisher data channels as negotiated (fixed IDs) in single peer connection mode when the server supports it (protocol 18), saving the data channel open round trip. Falls back to in-band channels against older servers. diff --git a/src/room/RTCEngine.test.ts b/src/room/RTCEngine.test.ts index 825ad40ec5..6b79d83efc 100644 --- a/src/room/RTCEngine.test.ts +++ b/src/room/RTCEngine.test.ts @@ -578,6 +578,40 @@ describe('RTCEngine', () => { internals.dataChannels.lossy.stopThresholdTuning(); }); + + it.each([ + ['publisher-only', 18, true], + ['publisher-only', 17, false], + ['publisher-only', undefined, false], + ['subscriber-primary', 18, false], + ])( + 'mode %s with server protocol %s creates negotiated channels: %s', + (mode, protocol, expected) => { + const engine = new RTCEngine(roomOptionDefaults); + const pcManager = { + mode, + createPublisherDataChannel: vi.fn((label: string) => + Object.assign(new FakeDataChannel(), { label }), + ), + }; + Object.assign(engine as unknown as Record, { + pcManager, + latestJoinResponse: { serverInfo: protocol === undefined ? undefined : { protocol } }, + }); + const internals = engine as unknown as { + createDataChannels: () => void; + dataChannels: { + publisherChannelsNegotiated: boolean; + lossy: { stopThresholdTuning: () => void }; + }; + }; + + internals.createDataChannels(); + + expect(internals.dataChannels.publisherChannelsNegotiated).toBe(expected); + internals.dataChannels.lossy.stopThresholdTuning(); + }, + ); }); describe('handleDataChannelClose', () => { diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index e71dab0ee4..c403949ef0 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -65,6 +65,7 @@ import type { InternalRoomOptions } from '../options'; import type { NonSharedUint8Array } from '../type-polyfills/non-shared-typed-arrays'; import TypedPromise from '../utils/TypedPromise'; import { TTLMap } from '../utils/ttlmap'; +import { PROTOCOL_NEGOTIATED_DATA_CHANNELS } from '../version'; import PCTransport, { PCEvents } from './PCTransport'; import { PCTransportManager, PCTransportState } from './PCTransportManager'; import type { ReconnectContext, ReconnectPolicy } from './ReconnectPolicy'; @@ -379,7 +380,9 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit // createInitialOffer() defers setLocalDescription (via pendingInitialOffer) until the // answer is applied, after updateConfiguration() has set the server's TURN servers. await this.configure(); - this.applyInitialPublisherLayout(); + // The server protocol is unknown until the join response, so assume negotiated data + // channels and fall back to in-band ones below if the server is too old. + this.applyInitialPublisherLayout(true); } const offer = await this.pcManager?.publisher.createInitialOffer(); if (offer) { @@ -414,6 +417,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.subscriberPrimary = joinResponse.subscriberPrimary; if (sendOfferWithJoin) { this.pcManager?.updateConfiguration(this.makeRTCConfiguration(joinResponse)); + if (this.dataChannels.publisherChannelsNegotiated && !this.useNegotiatedDataChannels()) { + // The SCTP section is already in the offer, so in-band channels can be opened on it + // without renegotiation. + this.log.debug('server does not support negotiated data channels, using in-band ones'); + this.createDataChannels(false); + } } else { if (!this.pcManager) { // Deferred path (Firefox, and V0): configure with the join response so the PC picks up @@ -902,8 +911,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit * present in that first offer, and the offer-with-join path needs the sections to * build a meaningful initial offer. Must be called on a configured pcManager. */ - private applyInitialPublisherLayout() { - this.createDataChannels(); + private applyInitialPublisherLayout(negotiatedDataChannels?: boolean) { + this.createDataChannels(negotiatedDataChannels); /** * Native libwebrtc does not support pre-populating the media sections, * so we skip it for React Native. @@ -932,7 +941,19 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } } - private createDataChannels() { + /** + * Whether to create the publisher data channels as negotiated (fixed SCTP stream IDs), which + * lets the server send on them without waiting for our DCEP OPEN. Only in single peer + * connection mode: with dual peer connections the server opens its own channels anyway. + */ + private useNegotiatedDataChannels() { + return ( + this.pcManager?.mode === 'publisher-only' && + (this.latestJoinResponse?.serverInfo?.protocol ?? 0) >= PROTOCOL_NEGOTIATED_DATA_CHANNELS + ); + } + + private createDataChannels(negotiated: boolean = this.useNegotiatedDataChannels()) { if (!this.pcManager) { return; } @@ -943,7 +964,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit // them to open. this.publisherConnectionPromise = undefined; } - this.dataChannels.createPublisherChannels(this.pcManager); + this.dataChannels.createPublisherChannels(this.pcManager, negotiated); } private handleDataChannel = async ({ channel }: RTCDataChannelEvent) => { diff --git a/src/room/data-channel/DataChannelManager.test.ts b/src/room/data-channel/DataChannelManager.test.ts index 688e267481..cc61db0c8f 100644 --- a/src/room/data-channel/DataChannelManager.test.ts +++ b/src/room/data-channel/DataChannelManager.test.ts @@ -126,6 +126,42 @@ describe('DataChannelManager', () => { manager.lossy.stopThresholdTuning(); }); + it('creates negotiated channels with fixed ids and can fall back to in-band ones', () => { + const { manager, pcManager } = makeManager(); + const createSpy = vi.mocked(pcManager.createPublisherDataChannel); + + manager.createPublisherChannels(pcManager, true); + + expect(manager.publisherChannelsNegotiated).toBe(true); + expect(createSpy).toHaveBeenCalledWith('_reliable', { + ordered: true, + negotiated: true, + id: 100, + }); + expect(createSpy).toHaveBeenCalledWith('_lossy', { + ordered: false, + maxRetransmits: 0, + negotiated: true, + id: 101, + }); + expect(createSpy).toHaveBeenCalledWith('_data_track', { + ordered: false, + maxRetransmits: 0, + negotiated: true, + id: 102, + }); + + createSpy.mockClear(); + manager.createPublisherChannels(pcManager, false); + + expect(manager.publisherChannelsNegotiated).toBe(false); + expect(createSpy).toHaveBeenCalledWith('_reliable', { ordered: true }); + manager.teardown(); + expect(manager.publisherChannelsNegotiated).toBe(false); + + manager.lossy.stopThresholdTuning(); + }); + it('adopts subscriber channels by label and rejects unknown labels', () => { const { manager, opts } = makeManager(); diff --git a/src/room/data-channel/DataChannelManager.ts b/src/room/data-channel/DataChannelManager.ts index b1b4ac6348..9a4758877c 100644 --- a/src/room/data-channel/DataChannelManager.ts +++ b/src/room/data-channel/DataChannelManager.ts @@ -8,6 +8,12 @@ const lossyDataChannelLabel = '_lossy'; const reliableDataChannelLabel = '_reliable'; const dataTrackDataChannelLabel = '_data_track'; +// Fixed SCTP stream IDs for negotiated publisher channels; must match the server +// (NegotiatedDataChannelID in livekit_rtc.proto). +const reliableNegotiatedId = 100; +const lossyNegotiatedId = 101; +const dataTrackNegotiatedId = 102; + export interface DataChannelManagerOptions { /** Whether the owning engine has been closed — a closed engine rejects headroom waiters. */ isEngineClosed: () => boolean; @@ -58,6 +64,8 @@ export class DataChannelManager { private opts: DataChannelManagerOptions; + private negotiated = false; + constructor(opts: DataChannelManagerOptions) { this.opts = opts; const flowControlOptions = (kind: DataChannelKind) => ({ @@ -119,14 +127,23 @@ export class DataChannelManager { ); } + /** Whether the current publisher channels were created as negotiated (fixed IDs). */ + get publisherChannelsNegotiated(): boolean { + return this.hasPublisherChannels && this.negotiated; + } + /** * Creates the three publisher data channels on the given transport, wires their handlers, and * attaches them to the wrappers — attaching closes any replaced channel objects and rejects the * waiters still parked on them. + * + * With `negotiated`, the channels use fixed SCTP stream IDs agreed with the server instead of + * the in-band DCEP open handshake, so the server can send on them right away. */ - createPublisherChannels(pcManager: PCTransportManager) { - // Recreating (the Safari null-id path): attaching the new handles below closes the old ones, - // so unhook them first — the old channel's close must not report an unexpected close. + createPublisherChannels(pcManager: PCTransportManager, negotiated: boolean = false) { + // Recreating (the Safari null-id path, or the in-band fallback after join): attaching the new + // handles below closes the old ones, so unhook them first — the old channel's close must not + // report an unexpected close. for (const channel of [this.lossy, this.reliable, this.dataTrack]) { const old = channel.channelHandle; if (old) { @@ -152,11 +169,14 @@ export class DataChannelManager { channel.attach(dc); }; + const idFor = (id: number) => (negotiated ? { negotiated, id } : {}); + wire( this.lossy, pcManager.createPublisherDataChannel(lossyDataChannelLabel, { ordered: false, maxRetransmits: 0, + ...idFor(lossyNegotiatedId), }), this.opts.onDataMessage, ); @@ -164,6 +184,7 @@ export class DataChannelManager { this.reliable, pcManager.createPublisherDataChannel(reliableDataChannelLabel, { ordered: true, + ...idFor(reliableNegotiatedId), }), this.opts.onDataMessage, ); @@ -172,9 +193,11 @@ export class DataChannelManager { pcManager.createPublisherDataChannel(dataTrackDataChannelLabel, { ordered: false, maxRetransmits: 0, + ...idFor(dataTrackNegotiatedId), }), this.opts.onDataTrackMessage, ); + this.negotiated = negotiated; this.lossy.startThresholdTuning(); } diff --git a/src/version.ts b/src/version.ts index 75ad4d28d6..837a988232 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,7 +1,11 @@ import { version as v } from '../package.json'; export const version = v; -export const protocolVersion = 17; +export const protocolVersion = 18; + +/** First server protocol version that creates the single peer connection data channels as + * negotiated (fixed SCTP stream IDs) instead of opening them in-band. */ +export const PROTOCOL_NEGOTIATED_DATA_CHANNELS = 18; /** Initial client protocol. */ export const CLIENT_PROTOCOL_DEFAULT = 0; From fcb7d70696a37c3fb99e9a45f2ff8b81d34b2413 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Mon, 19 Oct 2026 14:38:23 +0200 Subject: [PATCH 2/2] Use proto-defined negotiated data channel IDs Bump @livekit/protocol to 1.53.0, which defines NegotiatedDataChannelID, and drop the local ID constants. Co-Authored-By: Claude Opus 5.5 --- package.json | 4 ++-- pnpm-lock.yaml | 12 ++++++------ src/room/data-channel/DataChannelManager.ts | 13 ++++--------- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 2523a26101..d108f99cec 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ }, "dependencies": { "@livekit/mutex": "1.1.1", - "@livekit/protocol": "1.52.1", + "@livekit/protocol": "1.53.0", "events": "^3.3.0", "jose": "^6.1.0", "loglevel": "^1.9.2", @@ -94,6 +94,7 @@ "@eslint/js": "10.0.1", "@livekit/changesets-changelog-github": "^0.2.0", "@livekit/throws-transformer": "^0.1.3", + "@nbilyk/downlevel-dts": "^0.15.2", "@rollup/plugin-babel": "7.1.0", "@rollup/plugin-commonjs": "29.0.3", "@rollup/plugin-json": "6.1.0", @@ -111,7 +112,6 @@ "@typescript/native": "catalog:", "@vitest/browser": "^4.1.10", "@vitest/browser-playwright": "^4.1.10", - "@nbilyk/downlevel-dts": "^0.15.2", "eslint": "10.7.0", "eslint-config-airbnb-extended": "^2.3.2", "eslint-config-prettier": "10.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e24149076a..f1e57d6dd2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: specifier: 1.1.1 version: 1.1.1 '@livekit/protocol': - specifier: 1.52.1 - version: 1.52.1 + specifier: 1.53.0 + version: 1.53.0 '@types/dom-mediacapture-record': specifier: ^1 version: 1.0.22 @@ -1120,8 +1120,8 @@ packages: '@livekit/mutex@1.1.1': resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==} - '@livekit/protocol@1.52.1': - resolution: {integrity: sha512-X8DxKlNbzk6Lh+v630+mjdFHSotdAFwIyzxSRSUql2DbTxCnlpa7JsvFGc6Na3mXiex2OFh0iiQtWwOBP0rIYA==} + '@livekit/protocol@1.53.0': + resolution: {integrity: sha512-dM562Q7sEQbJ9ZghjIpcVuRxRApGNwJW/JfXRub+dJT8J1cC2Tep0SyYkDzOawix7+0x969fnjXt0qEZOxkjbQ==} '@livekit/throws-transformer@0.1.3': resolution: {integrity: sha512-PBttE6W6g/2ALGu6kWOunZ5qdrXwP9Ge1An2/62OfE6Rhc0Abd4yp6ex2pWhwUfGxDsSZvFgoB1Ia/5mWAMuKQ==} @@ -5739,7 +5739,7 @@ snapshots: '@livekit/mutex@1.1.1': {} - '@livekit/protocol@1.52.1': + '@livekit/protocol@1.53.0': dependencies: '@bufbuild/protobuf': 1.10.1 @@ -8290,7 +8290,7 @@ snapshots: livekit-server-sdk@2.17.0: dependencies: '@bufbuild/protobuf': 1.10.1 - '@livekit/protocol': 1.52.1 + '@livekit/protocol': 1.53.0 jose: 5.10.0 loader-runner@4.3.2: {} diff --git a/src/room/data-channel/DataChannelManager.ts b/src/room/data-channel/DataChannelManager.ts index 9a4758877c..282a9924a5 100644 --- a/src/room/data-channel/DataChannelManager.ts +++ b/src/room/data-channel/DataChannelManager.ts @@ -1,3 +1,4 @@ +import { NegotiatedDataChannelID } from '@livekit/protocol'; import type { PCTransportManager } from '../PCTransportManager'; import { FlowControlledDataChannel } from './FlowControlledDataChannel'; import { LossyDataChannel } from './LossyDataChannel'; @@ -8,12 +9,6 @@ const lossyDataChannelLabel = '_lossy'; const reliableDataChannelLabel = '_reliable'; const dataTrackDataChannelLabel = '_data_track'; -// Fixed SCTP stream IDs for negotiated publisher channels; must match the server -// (NegotiatedDataChannelID in livekit_rtc.proto). -const reliableNegotiatedId = 100; -const lossyNegotiatedId = 101; -const dataTrackNegotiatedId = 102; - export interface DataChannelManagerOptions { /** Whether the owning engine has been closed — a closed engine rejects headroom waiters. */ isEngineClosed: () => boolean; @@ -176,7 +171,7 @@ export class DataChannelManager { pcManager.createPublisherDataChannel(lossyDataChannelLabel, { ordered: false, maxRetransmits: 0, - ...idFor(lossyNegotiatedId), + ...idFor(NegotiatedDataChannelID.NDCI_LOSSY), }), this.opts.onDataMessage, ); @@ -184,7 +179,7 @@ export class DataChannelManager { this.reliable, pcManager.createPublisherDataChannel(reliableDataChannelLabel, { ordered: true, - ...idFor(reliableNegotiatedId), + ...idFor(NegotiatedDataChannelID.NDCI_RELIABLE), }), this.opts.onDataMessage, ); @@ -193,7 +188,7 @@ export class DataChannelManager { pcManager.createPublisherDataChannel(dataTrackDataChannelLabel, { ordered: false, maxRetransmits: 0, - ...idFor(dataTrackNegotiatedId), + ...idFor(NegotiatedDataChannelID.NDCI_DATA_TRACK), }), this.opts.onDataTrackMessage, );