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/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/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..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'; @@ -58,6 +59,8 @@ export class DataChannelManager { private opts: DataChannelManagerOptions; + private negotiated = false; + constructor(opts: DataChannelManagerOptions) { this.opts = opts; const flowControlOptions = (kind: DataChannelKind) => ({ @@ -119,14 +122,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 +164,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(NegotiatedDataChannelID.NDCI_LOSSY), }), this.opts.onDataMessage, ); @@ -164,6 +179,7 @@ export class DataChannelManager { this.reliable, pcManager.createPublisherDataChannel(reliableDataChannelLabel, { ordered: true, + ...idFor(NegotiatedDataChannelID.NDCI_RELIABLE), }), this.opts.onDataMessage, ); @@ -172,9 +188,11 @@ export class DataChannelManager { pcManager.createPublisherDataChannel(dataTrackDataChannelLabel, { ordered: false, maxRetransmits: 0, + ...idFor(NegotiatedDataChannelID.NDCI_DATA_TRACK), }), 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;