livekit-client 2.19.2 → 2.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +31 -27
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +334 -126
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js +2 -0
  8. package/dist/{livekit-client.pt.worker.js.map → livekit-client.fm.worker.js.map} +1 -1
  9. package/dist/{livekit-client.pt.worker.mjs → livekit-client.fm.worker.mjs} +12 -4
  10. package/dist/livekit-client.fm.worker.mjs.map +1 -0
  11. package/dist/livekit-client.umd.js +1 -1
  12. package/dist/livekit-client.umd.js.map +1 -1
  13. package/dist/src/api/SignalClient.d.ts.map +1 -1
  14. package/dist/src/e2ee/E2eeManager.d.ts +2 -1
  15. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  16. package/dist/src/e2ee/types.d.ts +6 -5
  17. package/dist/src/e2ee/types.d.ts.map +1 -1
  18. package/dist/src/e2ee/utils.d.ts +1 -0
  19. package/dist/src/e2ee/utils.d.ts.map +1 -1
  20. package/dist/src/e2ee/worker/DataCryptor.d.ts +1 -0
  21. package/dist/src/e2ee/worker/DataCryptor.d.ts.map +1 -1
  22. package/dist/src/e2ee/worker/FrameCryptor.d.ts +9 -8
  23. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  24. package/dist/src/e2ee/worker/naluUtils.d.ts +1 -0
  25. package/dist/src/e2ee/worker/naluUtils.d.ts.map +1 -1
  26. package/dist/src/e2ee/worker/sifPayload.d.ts +1 -0
  27. package/dist/src/e2ee/worker/sifPayload.d.ts.map +1 -1
  28. package/dist/src/{packetTrailer/PacketTrailerManager.d.ts → frameMetadata/FrameMetadataManager.d.ts} +8 -4
  29. package/dist/src/{packetTrailer/PacketTrailerManager.d.ts.map → frameMetadata/FrameMetadataManager.d.ts.map} +1 -1
  30. package/dist/src/{packetTrailer/packetTrailer.d.ts → frameMetadata/frameMetadata.d.ts} +8 -7
  31. package/dist/src/frameMetadata/frameMetadata.d.ts.map +1 -0
  32. package/dist/src/{packetTrailer → frameMetadata}/types.d.ts +11 -6
  33. package/dist/src/frameMetadata/types.d.ts.map +1 -0
  34. package/dist/src/frameMetadata/utils.d.ts +9 -0
  35. package/dist/src/{packetTrailer → frameMetadata}/utils.d.ts.map +1 -1
  36. package/dist/src/frameMetadata/worker/frameMetadata.worker.d.ts +2 -0
  37. package/dist/src/frameMetadata/worker/frameMetadata.worker.d.ts.map +1 -0
  38. package/dist/src/index.d.ts +10 -2
  39. package/dist/src/index.d.ts.map +1 -1
  40. package/dist/src/options.d.ts +8 -4
  41. package/dist/src/options.d.ts.map +1 -1
  42. package/dist/src/room/PCTransport.d.ts +41 -1
  43. package/dist/src/room/PCTransport.d.ts.map +1 -1
  44. package/dist/src/room/PCTransportManager.d.ts +1 -0
  45. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  46. package/dist/src/room/RTCEngine.d.ts +4 -1
  47. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  48. package/dist/src/room/Room.d.ts +3 -2
  49. package/dist/src/room/Room.d.ts.map +1 -1
  50. package/dist/src/room/data-track/depacketizer.d.ts.map +1 -1
  51. package/dist/src/room/data-track/frame.d.ts +1 -0
  52. package/dist/src/room/data-track/frame.d.ts.map +1 -1
  53. package/dist/src/room/data-track/outgoing/types.d.ts +1 -0
  54. package/dist/src/room/data-track/outgoing/types.d.ts.map +1 -1
  55. package/dist/src/room/data-track/packet/extensions.d.ts +1 -0
  56. package/dist/src/room/data-track/packet/extensions.d.ts.map +1 -1
  57. package/dist/src/room/data-track/packet/index.d.ts +1 -0
  58. package/dist/src/room/data-track/packet/index.d.ts.map +1 -1
  59. package/dist/src/room/data-track/packet/serializable.d.ts +1 -0
  60. package/dist/src/room/data-track/packet/serializable.d.ts.map +1 -1
  61. package/dist/src/room/participant/LocalParticipant.d.ts +3 -2
  62. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  63. package/dist/src/room/participant/Participant.d.ts +1 -0
  64. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  65. package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
  66. package/dist/src/room/token-source/TokenSource.d.ts.map +1 -1
  67. package/dist/src/room/token-source/types.d.ts +1 -0
  68. package/dist/src/room/token-source/types.d.ts.map +1 -1
  69. package/dist/src/room/token-source/utils.d.ts.map +1 -1
  70. package/dist/src/room/track/FrameMetadataExtractor.d.ts +19 -0
  71. package/dist/src/room/track/FrameMetadataExtractor.d.ts.map +1 -0
  72. package/dist/src/room/track/RemoteVideoTrack.d.ts +7 -7
  73. package/dist/src/room/track/RemoteVideoTrack.d.ts.map +1 -1
  74. package/dist/src/room/track/Track.d.ts +1 -0
  75. package/dist/src/room/track/Track.d.ts.map +1 -1
  76. package/dist/src/room/track/options.d.ts +17 -5
  77. package/dist/src/room/track/options.d.ts.map +1 -1
  78. package/dist/src/room/track/record.d.ts.map +1 -1
  79. package/dist/src/room/utils.d.ts +1 -0
  80. package/dist/src/room/utils.d.ts.map +1 -1
  81. package/dist/src/type-polyfills/non-shared-typed-arrays.d.ts +2 -0
  82. package/dist/src/type-polyfills/non-shared-typed-arrays.d.ts.map +1 -0
  83. package/dist/src/utils/dataPacketBuffer.d.ts +1 -0
  84. package/dist/src/utils/dataPacketBuffer.d.ts.map +1 -1
  85. package/dist/ts4.2/e2ee/E2eeManager.d.ts +2 -1
  86. package/dist/ts4.2/e2ee/types.d.ts +6 -5
  87. package/dist/ts4.2/e2ee/utils.d.ts +1 -0
  88. package/dist/ts4.2/e2ee/worker/DataCryptor.d.ts +1 -0
  89. package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +9 -8
  90. package/dist/ts4.2/e2ee/worker/naluUtils.d.ts +1 -0
  91. package/dist/ts4.2/e2ee/worker/sifPayload.d.ts +1 -0
  92. package/dist/ts4.2/{packetTrailer/PacketTrailerManager.d.ts → frameMetadata/FrameMetadataManager.d.ts} +8 -4
  93. package/dist/ts4.2/{packetTrailer/packetTrailer.d.ts → frameMetadata/frameMetadata.d.ts} +8 -7
  94. package/dist/ts4.2/{packetTrailer → frameMetadata}/types.d.ts +11 -6
  95. package/dist/ts4.2/frameMetadata/utils.d.ts +9 -0
  96. package/dist/ts4.2/frameMetadata/worker/frameMetadata.worker.d.ts +2 -0
  97. package/dist/ts4.2/index.d.ts +9 -3
  98. package/dist/ts4.2/options.d.ts +8 -4
  99. package/dist/ts4.2/room/PCTransport.d.ts +41 -1
  100. package/dist/ts4.2/room/PCTransportManager.d.ts +1 -0
  101. package/dist/ts4.2/room/RTCEngine.d.ts +4 -1
  102. package/dist/ts4.2/room/Room.d.ts +3 -2
  103. package/dist/ts4.2/room/data-track/frame.d.ts +1 -0
  104. package/dist/ts4.2/room/data-track/outgoing/types.d.ts +1 -0
  105. package/dist/ts4.2/room/data-track/packet/extensions.d.ts +1 -0
  106. package/dist/ts4.2/room/data-track/packet/index.d.ts +1 -0
  107. package/dist/ts4.2/room/data-track/packet/serializable.d.ts +1 -0
  108. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +3 -2
  109. package/dist/ts4.2/room/participant/Participant.d.ts +1 -0
  110. package/dist/ts4.2/room/token-source/types.d.ts +1 -0
  111. package/dist/ts4.2/room/track/FrameMetadataExtractor.d.ts +19 -0
  112. package/dist/ts4.2/room/track/RemoteVideoTrack.d.ts +7 -7
  113. package/dist/ts4.2/room/track/Track.d.ts +1 -0
  114. package/dist/ts4.2/room/track/options.d.ts +17 -5
  115. package/dist/ts4.2/room/utils.d.ts +1 -0
  116. package/dist/ts4.2/type-polyfills/non-shared-typed-arrays.d.ts +2 -0
  117. package/dist/ts4.2/utils/dataPacketBuffer.d.ts +1 -0
  118. package/package.json +24 -19
  119. package/src/api/SignalClient.test.ts +4 -4
  120. package/src/api/SignalClient.ts +8 -3
  121. package/src/api/WebSocketStream.test.ts +7 -7
  122. package/src/e2ee/E2eeManager.ts +16 -12
  123. package/src/e2ee/types.ts +6 -5
  124. package/src/e2ee/utils.ts +1 -0
  125. package/src/e2ee/worker/DataCryptor.ts +1 -0
  126. package/src/e2ee/worker/FrameCryptor.test.ts +6 -5
  127. package/src/e2ee/worker/FrameCryptor.ts +28 -23
  128. package/src/e2ee/worker/ParticipantKeyHandler.test.ts +1 -0
  129. package/src/e2ee/worker/e2ee.worker.ts +6 -5
  130. package/src/e2ee/worker/naluUtils.ts +1 -0
  131. package/src/e2ee/worker/sifPayload.ts +1 -0
  132. package/src/e2ee/worker/tsconfig.json +4 -1
  133. package/src/{packetTrailer/PacketTrailerManager.test.ts → frameMetadata/FrameMetadataManager.test.ts} +10 -8
  134. package/src/{packetTrailer/PacketTrailerManager.ts → frameMetadata/FrameMetadataManager.ts} +21 -15
  135. package/src/{packetTrailer/packetTrailer.test.ts → frameMetadata/frameMetadata.test.ts} +84 -1
  136. package/src/{packetTrailer/packetTrailer.ts → frameMetadata/frameMetadata.ts} +17 -9
  137. package/src/{packetTrailer → frameMetadata}/types.ts +13 -6
  138. package/src/{packetTrailer → frameMetadata}/utils.test.ts +19 -19
  139. package/src/{packetTrailer → frameMetadata}/utils.ts +11 -11
  140. package/src/{packetTrailer/worker/packetTrailer.worker.ts → frameMetadata/worker/frameMetadata.worker.ts} +5 -5
  141. package/src/{packetTrailer → frameMetadata}/worker/tsconfig.json +4 -1
  142. package/src/index.ts +13 -2
  143. package/src/options.ts +9 -4
  144. package/src/room/PCTransport.test.ts +150 -0
  145. package/src/room/PCTransport.ts +117 -0
  146. package/src/room/PCTransportManager.ts +4 -0
  147. package/src/room/RTCEngine.test.ts +143 -9
  148. package/src/room/RTCEngine.ts +112 -31
  149. package/src/room/Room.ts +11 -10
  150. package/src/room/data-track/depacketizer.ts +1 -0
  151. package/src/room/data-track/frame.ts +1 -0
  152. package/src/room/data-track/outgoing/types.ts +1 -0
  153. package/src/room/data-track/packet/extensions.ts +1 -0
  154. package/src/room/data-track/packet/index.ts +1 -0
  155. package/src/room/data-track/packet/serializable.ts +1 -0
  156. package/src/room/participant/LocalParticipant.test.ts +19 -19
  157. package/src/room/participant/LocalParticipant.ts +22 -16
  158. package/src/room/participant/Participant.ts +1 -0
  159. package/src/room/participant/publishUtils.test.ts +6 -0
  160. package/src/room/participant/publishUtils.ts +4 -1
  161. package/src/room/rpc/client/RpcClientManager.ts +1 -1
  162. package/src/room/rpc/server/RpcServerManager.ts +1 -1
  163. package/src/room/token-source/TokenSource.ts +9 -23
  164. package/src/room/token-source/types.ts +1 -0
  165. package/src/room/token-source/utils.ts +1 -0
  166. package/src/room/track/{PacketTrailerExtractor.ts → FrameMetadataExtractor.ts} +8 -8
  167. package/src/room/track/RemoteVideoTrack.ts +7 -11
  168. package/src/room/track/Track.ts +1 -0
  169. package/src/room/track/options.ts +19 -5
  170. package/src/room/track/record.ts +1 -0
  171. package/src/room/utils.ts +1 -0
  172. package/src/type-polyfills/{non-shared-typed-arrays.d.ts → non-shared-typed-arrays.ts} +6 -1
  173. package/src/utils/dataPacketBuffer.ts +2 -0
  174. package/dist/livekit-client.pt.worker.js +0 -2
  175. package/dist/livekit-client.pt.worker.mjs.map +0 -1
  176. package/dist/src/packetTrailer/packetTrailer.d.ts.map +0 -1
  177. package/dist/src/packetTrailer/types.d.ts.map +0 -1
  178. package/dist/src/packetTrailer/utils.d.ts +0 -9
  179. package/dist/src/packetTrailer/worker/packetTrailer.worker.d.ts +0 -2
  180. package/dist/src/packetTrailer/worker/packetTrailer.worker.d.ts.map +0 -1
  181. package/dist/src/room/track/PacketTrailerExtractor.d.ts +0 -19
  182. package/dist/src/room/track/PacketTrailerExtractor.d.ts.map +0 -1
  183. package/dist/ts4.2/packetTrailer/utils.d.ts +0 -9
  184. package/dist/ts4.2/packetTrailer/worker/packetTrailer.worker.d.ts +0 -2
  185. package/dist/ts4.2/room/track/PacketTrailerExtractor.d.ts +0 -19
@@ -0,0 +1,150 @@
1
+ import { type MediaDescription, parse } from 'sdp-transform';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { conformBundledCodecFmtp, placeholderMidsFromTransceivers } from './PCTransport';
4
+
5
+ /** Parse the `key[=value]` pairs of an fmtp config into a comparable set. */
6
+ const paramSet = (config: string) => new Set(config.split(';').filter(Boolean));
7
+
8
+ const fmtpOf = (media: MediaDescription[], mid: string, payload: number) => {
9
+ const m = media.find((section) => `${section.mid}` === mid)!;
10
+ return m.fmtp.find((f) => f.payload === payload)?.config;
11
+ };
12
+
13
+ /** Predicate for conformBundledCodecFmtp based on an explicit set of mids. */
14
+ const placeholders = (mids: string[]) => {
15
+ const set = new Set(mids);
16
+ return (m: MediaDescription) => set.has(`${m.mid}`);
17
+ };
18
+
19
+ // One publisher bundle: a published mic + camera (real sends, with msid/ssrc)
20
+ // and their pre-populated recvonly placeholders.
21
+ const PUBLISHER_SDP = `v=0
22
+ o=- 0 0 IN IP4 127.0.0.1
23
+ s=-
24
+ t=0 0
25
+ a=group:BUNDLE 0 1 2 3
26
+ m=audio 9 UDP/TLS/RTP/SAVPF 111
27
+ c=IN IP4 0.0.0.0
28
+ a=mid:0
29
+ a=sendonly
30
+ a=msid:s mic
31
+ a=ssrc:1111 cname:a
32
+ a=rtpmap:111 opus/48000/2
33
+ a=fmtp:111 minptime=10;useinbandfec=1;usedtx=1
34
+ m=audio 9 UDP/TLS/RTP/SAVPF 111
35
+ c=IN IP4 0.0.0.0
36
+ a=mid:1
37
+ a=recvonly
38
+ a=rtpmap:111 opus/48000/2
39
+ a=fmtp:111 minptime=10;useinbandfec=1
40
+ m=video 9 UDP/TLS/RTP/SAVPF 49
41
+ c=IN IP4 0.0.0.0
42
+ a=mid:2
43
+ a=sendonly
44
+ a=msid:s cam
45
+ a=ssrc:2222 cname:b
46
+ a=rtpmap:49 H265/90000
47
+ a=fmtp:49 level-id=186;profile-id=1;tier-flag=0;tx-mode=SRST
48
+ m=video 9 UDP/TLS/RTP/SAVPF 49
49
+ c=IN IP4 0.0.0.0
50
+ a=mid:3
51
+ a=recvonly
52
+ a=rtpmap:49 H265/90000
53
+ a=fmtp:49 level-id=180;profile-id=1;tier-flag=0;tx-mode=SRST`;
54
+
55
+ describe('placeholderMidsFromTransceivers', () => {
56
+ const tr = (mid: string | null, track: unknown) =>
57
+ ({ mid, sender: { track } }) as unknown as RTCRtpTransceiver;
58
+
59
+ it('includes transceivers with a mid and no outgoing track', () => {
60
+ const mids = placeholderMidsFromTransceivers([
61
+ tr('0', {}), // real send
62
+ tr('1', null), // pre-populated placeholder
63
+ tr('2', null), // reverted after unpublish (msid may linger in SDP, but no track)
64
+ tr(null, null), // not yet negotiated — no mid, excluded
65
+ ]);
66
+ expect(mids).toEqual(new Set(['1', '2']));
67
+ });
68
+ });
69
+
70
+ describe('conformBundledCodecFmtp', () => {
71
+ it('copies opus and H.265 fmtp from real sections onto placeholders', () => {
72
+ const { media } = parse(PUBLISHER_SDP);
73
+
74
+ conformBundledCodecFmtp(media, placeholders(['1', '3']));
75
+
76
+ expect(fmtpOf(media, '1', 111)).toBe(fmtpOf(media, '0', 111));
77
+ expect(paramSet(fmtpOf(media, '1', 111)!)).toContain('usedtx=1');
78
+ expect(fmtpOf(media, '3', 49)).toBe(fmtpOf(media, '2', 49));
79
+ expect(paramSet(fmtpOf(media, '3', 49)!)).toContain('level-id=186');
80
+ });
81
+
82
+ it('never rewrites the real (non-placeholder) sections', () => {
83
+ const { media } = parse(PUBLISHER_SDP);
84
+
85
+ conformBundledCodecFmtp(media, placeholders(['1', '3']));
86
+
87
+ expect(fmtpOf(media, '0', 111)).toBe('minptime=10;useinbandfec=1;usedtx=1');
88
+ expect(paramSet(fmtpOf(media, '2', 49)!)).toContain('level-id=186');
89
+ });
90
+
91
+ it('converges divergent placeholders even when no real section declares the payload', () => {
92
+ // Two placeholders that disagree (a reverted one kept level 186, a fresh one
93
+ // has 180) with no active send present.
94
+ const { media } = parse(`v=0
95
+ o=- 0 0 IN IP4 127.0.0.1
96
+ s=-
97
+ t=0 0
98
+ a=group:BUNDLE 10 11
99
+ m=video 9 UDP/TLS/RTP/SAVPF 49
100
+ a=mid:10
101
+ a=rtpmap:49 H265/90000
102
+ a=fmtp:49 level-id=186;profile-id=1;tier-flag=0;tx-mode=SRST
103
+ m=video 9 UDP/TLS/RTP/SAVPF 49
104
+ a=mid:11
105
+ a=rtpmap:49 H265/90000
106
+ a=fmtp:49 level-id=180;profile-id=1;tier-flag=0;tx-mode=SRST`);
107
+
108
+ conformBundledCodecFmtp(media, placeholders(['10', '11']));
109
+
110
+ expect(fmtpOf(media, '10', 49)).toBe(fmtpOf(media, '11', 49));
111
+ });
112
+
113
+ it('conforms a reverted section that kept its msid (second-publish case)', () => {
114
+ // The real second publish: a live camera send (level 186), plus the section
115
+ // left over from the first (unpublished) track. Chrome keeps that section's
116
+ // `a=msid`/`a=ssrc` even though it no longer sends, so it must be identified
117
+ // as a placeholder by its transceiver (no track), not by SDP.
118
+ const { media } = parse(`v=0
119
+ o=- 0 0 IN IP4 127.0.0.1
120
+ s=-
121
+ t=0 0
122
+ a=group:BUNDLE 3 4 5
123
+ m=video 9 UDP/TLS/RTP/SAVPF 49
124
+ a=mid:3
125
+ a=sendonly
126
+ a=msid:s cam
127
+ a=ssrc:9 cname:c
128
+ a=rtpmap:49 H265/90000
129
+ a=fmtp:49 level-id=186;profile-id=1;tier-flag=0;tx-mode=SRST
130
+ m=video 9 UDP/TLS/RTP/SAVPF 49
131
+ a=mid:4
132
+ a=sendonly
133
+ a=msid:s stale
134
+ a=ssrc:8 cname:d
135
+ a=rtpmap:49 H265/90000
136
+ a=fmtp:49 level-id=180;profile-id=1;tier-flag=0;tx-mode=SRST
137
+ m=video 9 UDP/TLS/RTP/SAVPF 49
138
+ a=mid:5
139
+ a=recvonly
140
+ a=rtpmap:49 H265/90000
141
+ a=fmtp:49 level-id=180;profile-id=1;tier-flag=0;tx-mode=SRST`);
142
+
143
+ // mid 4 still has msid/ssrc in the SDP, but its transceiver has no track.
144
+ conformBundledCodecFmtp(media, placeholders(['4', '5']));
145
+
146
+ expect(paramSet(fmtpOf(media, '3', 49)!)).toContain('level-id=186'); // real send untouched
147
+ expect(fmtpOf(media, '4', 49)).toBe(fmtpOf(media, '3', 49)); // stale conformed
148
+ expect(fmtpOf(media, '5', 49)).toBe(fmtpOf(media, '3', 49)); // placeholder conformed
149
+ });
150
+ });
@@ -234,6 +234,17 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
234
234
  });
235
235
  }
236
236
  });
237
+ // The server's answer sets per-track codec params (e.g. opus `usedtx`) on
238
+ // the sections mapped to a published track, but leaves the pre-populated
239
+ // recvonly placeholder sections with defaults. Conform the placeholders so
240
+ // each shared payload type is consistent across the bundle, otherwise
241
+ // libwebrtc flags a "bundled payload type collision".
242
+ const placeholderMids = this.getPlaceholderMids();
243
+ if (placeholderMids.size > 0) {
244
+ conformBundledCodecFmtp(sdpParsed.media, (media) =>
245
+ placeholderMids.has(getMidString(media.mid!)),
246
+ );
247
+ }
237
248
  mungedSDP = write(sdpParsed);
238
249
  }
239
250
  await this.setMungedSDP(sd, mungedSDP, true);
@@ -400,6 +411,17 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
400
411
  });
401
412
  }
402
413
  });
414
+ // Conform the placeholder sections (pre-populated, or reverted after an
415
+ // unpublish) so every shared payload type carries identical fmtp across the
416
+ // bundle, otherwise libwebrtc flags a "bundled payload type collision".
417
+ // Detection is by transceiver (mids are stable across renegotiations) since
418
+ // an unpublished section keeps its `a=msid`.
419
+ const placeholderMids = this.getPlaceholderMids();
420
+ if (placeholderMids.size > 0) {
421
+ conformBundledCodecFmtp(sdpParsed.media, (media) =>
422
+ placeholderMids.has(getMidString(media.mid!)),
423
+ );
424
+ }
403
425
  if (this.latestOfferId > offerId) {
404
426
  this.log.warn('latestOfferId mismatch', {
405
427
  ...this.logContext,
@@ -428,6 +450,17 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
428
450
  return answer;
429
451
  }
430
452
 
453
+ /**
454
+ * Returns the mids of transceivers that carry no outgoing track on this
455
+ * (publisher) connection: the pre-populated placeholders added by
456
+ * `RTCEngine.applyInitialPublisherLayout`, plus any transceiver that was used
457
+ * for a track and reverted on unpublish. Their codec fmtp is conformed to the
458
+ * published tracks so a shared payload type stays consistent across the bundle.
459
+ */
460
+ private getPlaceholderMids(): Set<string> {
461
+ return placeholderMidsFromTransceivers(this._pc?.getTransceivers() ?? []);
462
+ }
463
+
431
464
  createDataChannel(label: string, dataChannelDict: RTCDataChannelInit) {
432
465
  return this.pc.createDataChannel(label, dataChannelDict);
433
466
  }
@@ -498,6 +531,10 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter
498
531
  return this.pc.getStats();
499
532
  }
500
533
 
534
+ getMaxMessageSize() {
535
+ return this._pc?.sctp?.maxMessageSize;
536
+ }
537
+
501
538
  async getConnectedAddress(): Promise<string | undefined> {
502
539
  if (!this._pc) {
503
540
  return;
@@ -699,6 +736,86 @@ function ensureAudioNackAndStereo(
699
736
  }
700
737
  }
701
738
 
739
+ /**
740
+ * Returns the mids of transceivers that carry no outgoing track: the
741
+ * pre-populated placeholders added by `RTCEngine.applyInitialPublisherLayout`,
742
+ * plus any transceiver that was used for a track and reverted on unpublish. The
743
+ * `sender.track` check is the reliable signal — an unpublished section keeps its
744
+ * `a=msid` (and its stale send-derived fmtp), so it can't be told apart from a
745
+ * real send by SDP alone. Transceiver mids are stable across renegotiations, so
746
+ * this works for every offer/answer after the first.
747
+ * @internal
748
+ */
749
+ export function placeholderMidsFromTransceivers(
750
+ transceivers: readonly RTCRtpTransceiver[],
751
+ ): Set<string> {
752
+ const mids = new Set<string>();
753
+ for (const transceiver of transceivers) {
754
+ if (transceiver.mid && !transceiver.sender.track) {
755
+ mids.add(transceiver.mid);
756
+ }
757
+ }
758
+ return mids;
759
+ }
760
+
761
+ /**
762
+ * Within a BUNDLE group a payload type must map to identical codec parameters
763
+ * across every m-line. When the same payload type carries different fmtp between
764
+ * sections — e.g. opus `usedtx=1` on the published microphone but not on the
765
+ * pre-populated recvonly placeholders, or H.265 with different `level-id` between
766
+ * a published video track and a placeholder — libwebrtc flags a "bundled payload
767
+ * type collision".
768
+ *
769
+ * Rewrite the placeholder sections so every shared payload type carries the
770
+ * same fmtp. Real (non-placeholder) sections always win the canonical value, so
771
+ * a published track's encoder parameters are never altered. When no real
772
+ * section declares a payload type — e.g. a placeholder that was reused for a
773
+ * track and then reverted to recvonly keeps its send-derived `level-id` while
774
+ * fresh placeholders use the default — the placeholders still converge on the
775
+ * first value seen, so two placeholders can't disagree either. Only placeholder
776
+ * sections are ever rewritten, and it is codec-agnostic (opus, H.265, ...).
777
+ * `isPlaceholder` identifies the sections to conform.
778
+ * @internal
779
+ */
780
+ export function conformBundledCodecFmtp(
781
+ media: MediaDescription[],
782
+ isPlaceholder: (media: MediaDescription) => boolean,
783
+ ) {
784
+ // Canonical fmtp per payload type. Payload types are unique within a BUNDLE
785
+ // group, so keying by payload alone (across audio and video) is safe. A real
786
+ // section's value always takes precedence; otherwise the first placeholder
787
+ // value seen is used so divergent placeholders still converge.
788
+ const canonicalByPayload = new Map<number, string>();
789
+ const fromRealSection = new Set<number>();
790
+ for (const m of media) {
791
+ const placeholder = isPlaceholder(m);
792
+ for (const fmtp of m.fmtp ?? []) {
793
+ if (!placeholder) {
794
+ canonicalByPayload.set(fmtp.payload, fmtp.config);
795
+ fromRealSection.add(fmtp.payload);
796
+ } else if (!canonicalByPayload.has(fmtp.payload)) {
797
+ canonicalByPayload.set(fmtp.payload, fmtp.config);
798
+ }
799
+ }
800
+ }
801
+ if (canonicalByPayload.size === 0) {
802
+ return;
803
+ }
804
+
805
+ // Conform placeholder sections to the canonical fmtp for each shared payload.
806
+ for (const m of media) {
807
+ if (!isPlaceholder(m)) {
808
+ continue;
809
+ }
810
+ for (const fmtp of m.fmtp ?? []) {
811
+ const config = canonicalByPayload.get(fmtp.payload);
812
+ if (config !== undefined && fmtp.config !== config) {
813
+ fmtp.config = config;
814
+ }
815
+ }
816
+ }
817
+ }
818
+
702
819
  function extractStereoAndNackAudioFromOffer(offer: RTCSessionDescriptionInit): {
703
820
  stereoMids: string[];
704
821
  nackMids: string[];
@@ -310,6 +310,10 @@ export class PCTransportManager {
310
310
  return matchingTransceiver?.mid;
311
311
  }
312
312
 
313
+ getMaxPublisherMessageSize() {
314
+ return this.publisher.getMaxMessageSize();
315
+ }
316
+
313
317
  addPublisherTrack(track: MediaStreamTrack) {
314
318
  return this.publisher.addTrack(track);
315
319
  }
@@ -1,6 +1,8 @@
1
+ import { DataPacket, DataPacket_Kind, UserPacket } from '@livekit/protocol';
1
2
  import { afterEach, describe, expect, it, vi } from 'vitest';
2
- import RTCEngine from './RTCEngine';
3
+ import RTCEngine, { DataChannelKind } from './RTCEngine';
3
4
  import { roomOptionDefaults } from './defaults';
5
+ import { PublishDataError } from './errors';
4
6
 
5
7
  describe('RTCEngine', () => {
6
8
  const originalRTCRtpSender = window.RTCRtpSender;
@@ -56,12 +58,12 @@ describe('RTCEngine', () => {
56
58
  ).makeRTCConfiguration();
57
59
  }
58
60
 
59
- function setupPacketTrailerSender(engine: RTCEngine, sender: RTCRtpSender, opts = {}) {
61
+ function setupFrameMetadataSender(engine: RTCEngine, sender: RTCRtpSender, opts = {}) {
60
62
  (
61
63
  engine as unknown as {
62
- setupPacketTrailerSender: (sender: RTCRtpSender, opts?: unknown) => void;
64
+ setupFrameMetadataSender: (sender: RTCRtpSender, opts?: unknown) => void;
63
65
  }
64
- ).setupPacketTrailerSender(sender, opts);
66
+ ).setupFrameMetadataSender(sender, opts);
65
67
  }
66
68
 
67
69
  it('does not enable encoded insertable streams without E2EE or a packet trailer worker', () => {
@@ -128,7 +130,7 @@ describe('RTCEngine', () => {
128
130
  createEncodedStreams,
129
131
  } as unknown as RTCRtpSender;
130
132
 
131
- setupPacketTrailerSender(engine, sender);
133
+ setupFrameMetadataSender(engine, sender);
132
134
 
133
135
  expect(createEncodedStreams).not.toHaveBeenCalled();
134
136
  });
@@ -145,7 +147,7 @@ describe('RTCEngine', () => {
145
147
  createEncodedStreams,
146
148
  } as unknown as RTCRtpSender;
147
149
 
148
- setupPacketTrailerSender(engine, sender);
150
+ setupFrameMetadataSender(engine, sender);
149
151
 
150
152
  expect(createEncodedStreams).not.toHaveBeenCalled();
151
153
  });
@@ -165,7 +167,7 @@ describe('RTCEngine', () => {
165
167
  createEncodedStreams,
166
168
  } as unknown as RTCRtpSender;
167
169
 
168
- setupPacketTrailerSender(engine, sender, { packetTrailer: { timestamp: true, frameId: true } });
170
+ setupFrameMetadataSender(engine, sender, { packetTrailer: { timestamp: true, frameId: true } });
169
171
 
170
172
  expect(createEncodedStreams).toHaveBeenCalledTimes(1);
171
173
  expect(worker.postMessage).toHaveBeenCalledWith(
@@ -185,7 +187,9 @@ describe('RTCEngine', () => {
185
187
  stubScriptTransformSupport();
186
188
 
187
189
  const transform = {};
188
- const RTCRtpScriptTransform = vi.fn(() => transform);
190
+ const RTCRtpScriptTransform = vi.fn(function () {
191
+ return transform;
192
+ });
189
193
  Object.defineProperty(window, 'RTCRtpScriptTransform', {
190
194
  configurable: true,
191
195
  value: RTCRtpScriptTransform,
@@ -207,7 +211,7 @@ describe('RTCEngine', () => {
207
211
  createEncodedStreams,
208
212
  } as unknown as RTCRtpSender;
209
213
 
210
- setupPacketTrailerSender(engine, sender, { packetTrailer: { timestamp: true } });
214
+ setupFrameMetadataSender(engine, sender, { packetTrailer: { timestamp: true } });
211
215
 
212
216
  expect(RTCRtpScriptTransform).toHaveBeenCalledWith(worker, {
213
217
  kind: 'encode',
@@ -216,4 +220,134 @@ describe('RTCEngine', () => {
216
220
  expect((sender as unknown as { transform: unknown }).transform).toBe(transform);
217
221
  expect(createEncodedStreams).not.toHaveBeenCalled();
218
222
  });
223
+
224
+ describe('sendDataPacket', () => {
225
+ const MAX_DATA_PACKET_SIZE = 64 * 1024 - 1; // 65535 bytes (64 KB - 1)
226
+ function stubConnectedEngine(
227
+ engine: RTCEngine,
228
+ maxDataPacketSize: number = MAX_DATA_PACKET_SIZE,
229
+ ) {
230
+ const send = vi.fn();
231
+ Object.assign(engine as unknown as Record<string, unknown>, {
232
+ ensurePublisherConnected: vi.fn().mockResolvedValue(undefined),
233
+ waitForBufferStatusLow: vi.fn().mockResolvedValue(undefined),
234
+ updateAndEmitDCBufferStatus: vi.fn(),
235
+ dataChannelForKind: vi.fn(() => ({ send })),
236
+ pcManager: {
237
+ getMaxPublisherMessageSize: vi.fn(() => maxDataPacketSize),
238
+ },
239
+ });
240
+ return send;
241
+ }
242
+
243
+ it('rejects packets larger than the max data packet size', async () => {
244
+ const engine = new RTCEngine(roomOptionDefaults);
245
+ const send = stubConnectedEngine(engine);
246
+
247
+ // The serialized packet includes protobuf framing on top of the payload, so a payload at the
248
+ // limit is already guaranteed to exceed it once serialized.
249
+ const packet = new DataPacket({
250
+ kind: DataPacket_Kind.RELIABLE,
251
+ value: {
252
+ case: 'user',
253
+ value: new UserPacket({ payload: new Uint8Array(MAX_DATA_PACKET_SIZE) }),
254
+ },
255
+ });
256
+
257
+ await expect(engine.sendDataPacket(packet, DataChannelKind.RELIABLE)).rejects.toBeInstanceOf(
258
+ PublishDataError,
259
+ );
260
+ expect(send).not.toHaveBeenCalled();
261
+ });
262
+
263
+ it('does not reject packets if the max data packet size is 0', async () => {
264
+ const engine = new RTCEngine(roomOptionDefaults);
265
+ const send = stubConnectedEngine(engine, 0);
266
+
267
+ const packet = new DataPacket({
268
+ kind: DataPacket_Kind.RELIABLE,
269
+ value: {
270
+ case: 'user',
271
+ value: new UserPacket({ payload: new Uint8Array(100) }),
272
+ },
273
+ });
274
+
275
+ // Sending the packet should succeed, there isn't a size limit
276
+ await expect(
277
+ engine.sendDataPacket(packet, DataChannelKind.RELIABLE),
278
+ ).resolves.toBeUndefined();
279
+ expect(send).toHaveBeenCalledTimes(1);
280
+ });
281
+
282
+ it('sends packets within the max data packet size', async () => {
283
+ const engine = new RTCEngine(roomOptionDefaults);
284
+ const send = stubConnectedEngine(engine);
285
+
286
+ const packet = new DataPacket({
287
+ kind: DataPacket_Kind.RELIABLE,
288
+ value: {
289
+ case: 'user',
290
+ value: new UserPacket({ payload: new Uint8Array(1024) }),
291
+ },
292
+ });
293
+
294
+ await expect(
295
+ engine.sendDataPacket(packet, DataChannelKind.RELIABLE),
296
+ ).resolves.toBeUndefined();
297
+ expect(send).toHaveBeenCalledTimes(1);
298
+ });
299
+ });
300
+
301
+ describe('handleDataChannelClose', () => {
302
+ function stubCloseEnv(
303
+ engine: RTCEngine,
304
+ { closed, publisherState }: { closed: boolean; publisherState: RTCPeerConnectionState },
305
+ ) {
306
+ const error = vi.fn();
307
+ Object.assign(engine as unknown as Record<string, unknown>, {
308
+ _isClosed: closed,
309
+ log: { error },
310
+ pcManager: {
311
+ publisher: { getConnectionState: () => publisherState },
312
+ },
313
+ });
314
+ return error;
315
+ }
316
+
317
+ function fireClose(engine: RTCEngine, kind: DataChannelKind) {
318
+ (
319
+ engine as unknown as {
320
+ handleDataChannelClose: (kind: DataChannelKind) => () => void;
321
+ }
322
+ ).handleDataChannelClose(kind)();
323
+ }
324
+
325
+ it('logs an error when a publisher channel closes while connected', () => {
326
+ const engine = new RTCEngine(roomOptionDefaults);
327
+ const error = stubCloseEnv(engine, { closed: false, publisherState: 'connected' });
328
+
329
+ fireClose(engine, DataChannelKind.RELIABLE);
330
+
331
+ expect(error).toHaveBeenCalledOnce();
332
+ expect(error.mock.calls[0][0]).toContain('RELIABLE');
333
+ });
334
+
335
+ it('stays quiet when the engine is already closed', () => {
336
+ const engine = new RTCEngine(roomOptionDefaults);
337
+ const error = stubCloseEnv(engine, { closed: true, publisherState: 'connected' });
338
+
339
+ fireClose(engine, DataChannelKind.RELIABLE);
340
+
341
+ expect(error).not.toHaveBeenCalled();
342
+ });
343
+
344
+ it('stays quiet when the publisher PC is no longer connected', () => {
345
+ const engine = new RTCEngine(roomOptionDefaults);
346
+ const error = stubCloseEnv(engine, { closed: false, publisherState: 'closed' });
347
+
348
+ fireClose(engine, DataChannelKind.RELIABLE);
349
+
350
+ expect(error).not.toHaveBeenCalled();
351
+ });
352
+ });
219
353
  });