@@ -11,6 +11,8 @@ import {
11
11
TrackUnpublishedPayload ,
12
12
} from "./sfu" ;
13
13
14
+ const MAX_DOWNSTREAMS = 9 ;
15
+
14
16
export type ClientStatus =
15
17
| "new"
16
18
| "connecting"
@@ -40,16 +42,15 @@ export interface AppDataChannelConfig {
40
42
}
41
43
42
44
export class PulsebeamClient {
43
- #pc: RTCPeerConnection | null = null ;
44
- #sfuRpcCh: RTCDataChannel | null = null ;
45
- #appDataCh: RTCDataChannel | null = null ;
46
-
47
45
readonly #sfuUrl: string ;
48
- readonly #maxDownstreams: number ;
49
- readonly #appDataConfig?: AppDataChannelConfig ;
50
46
51
- #videoSender: RTCRtpSender | null = null ;
52
- #audioSender: RTCRtpSender | null = null ;
47
+ #pc: RTCPeerConnection ;
48
+ #sfuRpcCh: RTCDataChannel ;
49
+ #appDataCh: RTCDataChannel ;
50
+
51
+ #videoSender: RTCRtpSender ;
52
+ #audioSender: RTCRtpSender ;
53
+
53
54
#videoRecvMids: string [ ] = [ ] ;
54
55
#audioRecvMids: string [ ] = [ ] ;
55
56
#usedMids = new Set < string > ( ) ;
@@ -75,139 +76,13 @@ export class PulsebeamClient {
75
76
appDataConfig ?: AppDataChannelConfig ,
76
77
) {
77
78
this . #sfuUrl = sfuUrl ;
78
- this . #maxDownstreams = maxDownstreams ;
79
- this . #appDataConfig = appDataConfig ;
80
- }
81
-
82
- #terminateInstance(
83
- finalStatus : "disconnected" | "failed" ,
84
- message ?: string ,
85
- ) : void {
86
- if ( this . #instanceTerminated) {
87
- return ;
88
- }
89
- this . #instanceTerminated = true ;
90
- console . debug (
91
- `PulsebeamClient: Terminating instance with status: ${ finalStatus } , message: ${ message || "N/A"
92
- } `,
93
- ) ;
94
-
95
- this . localVideo . get ( ) ?. stop ( ) ;
96
- this . localAudio . get ( ) ?. stop ( ) ;
97
- this . localVideo . set ( null ) ;
98
- this . localAudio . set ( null ) ;
99
-
100
- Object . values ( this . remoteTracks . get ( ) ) . forEach ( ( remoteTrackInfo ) => {
101
- remoteTrackInfo ?. track ?. stop ( ) ;
102
- } ) ;
103
- this . remoteTracks . set ( { } ) ;
104
- this . availableTracks . set ( { } ) ;
105
-
106
- const cleanupChannel = ( channel : RTCDataChannel | null ) : void => {
107
- if ( channel ) {
108
- channel . onopen = null ;
109
- channel . onmessage = null ;
110
- channel . onclose = null ;
111
- channel . onerror = null ;
112
- if (
113
- channel . readyState === "open" || channel . readyState === "connecting"
114
- ) {
115
- try {
116
- channel . close ( ) ;
117
- } catch ( e ) {
118
- console . warn ( "Error closing data channel:" , e ) ;
119
- }
120
- }
121
- }
122
- } ;
123
-
124
- cleanupChannel ( this . #sfuRpcCh) ;
125
- this . #sfuRpcCh = null ;
126
- cleanupChannel ( this . #appDataCh) ;
127
- this . #appDataCh = null ;
128
-
129
- if ( this . #pc) {
130
- this . #pc. onconnectionstatechange = null ;
131
- this . #pc. ontrack = null ;
132
- this . #pc. onicecandidate = null ;
133
-
134
- this . #pc. getSenders ( ) . forEach ( ( sender ) => {
135
- sender . track ?. stop ( ) ;
136
- } ) ;
137
- // No need to explicitly stop receiver tracks here, as they are managed by remoteTracks cleanup
138
-
139
- if ( this . #pc. signalingState !== "closed" ) {
140
- try {
141
- this . #pc. close ( ) ;
142
- } catch ( e ) {
143
- console . warn ( "Error closing PeerConnection:" , e ) ;
144
- }
145
- }
146
- this . #pc = null ;
147
- }
148
-
149
- this . #activeSubscriptions. clear ( ) ;
150
- this . #usedMids. clear ( ) ;
151
- this . #videoRecvMids = [ ] ;
152
- this . #audioRecvMids = [ ] ;
153
- this . #videoSender = null ;
154
- this . #audioSender = null ;
155
-
156
- if ( message ) {
157
- this . errorMsg . set ( message ) ;
158
- }
159
- this . status . set ( finalStatus ) ;
160
- console . warn (
161
- "PulsebeamClient instance has been terminated and is no longer usable." ,
79
+ maxDownstreams = Math . max (
80
+ Math . min ( maxDownstreams , MAX_DOWNSTREAMS ) ,
81
+ 0 ,
162
82
) ;
163
- }
164
-
165
- #updateConnectedStatus( ) : void {
166
- if ( this . #instanceTerminated || this . status . get ( ) !== "connecting" ) {
167
- return ;
168
- }
169
-
170
- const pcConnected = this . #pc?. connectionState === "connected" ;
171
- const rpcReady = this . #sfuRpcCh?. readyState === "open" ;
172
- const appDcReady = ! this . #appDataConfig ||
173
- this . #appDataCh?. readyState === "open" ;
174
-
175
- if ( pcConnected && rpcReady && appDcReady ) {
176
- this . status . set ( "connected" ) ;
177
- this . errorMsg . set ( null ) ; // Clear any transient errors from connecting phase
178
- }
179
- }
180
-
181
- async connect ( room : string , participantId : string ) : Promise < void > {
182
- if ( this . #instanceTerminated) {
183
- const errorMessage =
184
- "This client instance has been terminated and cannot be reused." ;
185
- this . errorMsg . set ( errorMessage ) ;
186
- console . error ( errorMessage ) ;
187
- throw new Error ( errorMessage ) ; // More direct feedback to developer
188
- }
189
-
190
- if ( this . status . get ( ) !== "new" ) {
191
- const errorMessage =
192
- `Client can only connect when in "new" state. Current status: ${ this . status . get ( ) } . Create a new instance to reconnect.` ;
193
- // Only set error if it's not already a terminal state from a previous attempt on this (now invalid) instance
194
- if (
195
- this . status . get ( ) !== "failed" && this . status . get ( ) !== "disconnected"
196
- ) {
197
- this . errorMsg . set ( errorMessage ) ;
198
- }
199
- console . warn ( errorMessage ) ;
200
- return ; // Do not proceed
201
- }
202
-
203
- this . status . set ( "connecting" ) ;
204
- this . errorMsg . set ( null ) ;
205
83
206
84
this . #pc = new RTCPeerConnection ( ) ;
207
- const peerConnection = this . #pc; // Use a more descriptive local variable
208
- peerConnection . onicecandidate = null ; // No ICE trickling
209
-
210
- peerConnection . onconnectionstatechange = ( ) => {
85
+ this . #pc. onconnectionstatechange = ( ) => {
211
86
if ( this . #instanceTerminated || ! this . #pc) return ; // Guard
212
87
const connectionState = this . #pc. connectionState ;
213
88
console . debug ( `PeerConnection state changed: ${ connectionState } ` ) ;
@@ -225,7 +100,7 @@ export class PulsebeamClient {
225
100
}
226
101
} ;
227
102
228
- peerConnection . ontrack = ( event : RTCTrackEvent ) => {
103
+ this . #pc . ontrack = ( event : RTCTrackEvent ) => {
229
104
if ( this . #instanceTerminated) return ;
230
105
const mid = event . transceiver ?. mid ;
231
106
const track = event . track ;
@@ -269,7 +144,7 @@ export class PulsebeamClient {
269
144
} ;
270
145
271
146
// SFU RPC DataChannel
272
- this . #sfuRpcCh = peerConnection . createDataChannel ( "pulsebeam::rpc" ) ;
147
+ this . #sfuRpcCh = this . #pc . createDataChannel ( "pulsebeam::rpc" ) ;
273
148
this . #sfuRpcCh. binaryType = "arraybuffer" ;
274
149
this . #sfuRpcCh. onopen = ( ) => {
275
150
if ( ! this . #instanceTerminated) this . #updateConnectedStatus( ) ;
@@ -349,62 +224,123 @@ export class PulsebeamClient {
349
224
this . #sfuRpcCh. onclose = createFatalRpcHandler ( "closed" ) ;
350
225
this . #sfuRpcCh. onerror = createFatalRpcHandler ( "error" ) ;
351
226
352
- // Optional Application DataChannel
353
- if ( this . #appDataConfig) {
354
- this . #appDataCh = peerConnection . createDataChannel (
355
- "app-data" ,
356
- this . #appDataConfig. options ,
357
- ) ;
358
- this . #appDataCh. onmessage = ( event : MessageEvent ) => {
359
- if ( this . #instanceTerminated || ! this . #appDataConfig) return ;
360
- if ( typeof event . data === "string" ) {
361
- this . #appDataConfig. onMessage ( event . data ) ;
362
- } else {
363
- console . warn (
364
- "Received non-string message on app data channel, ignoring." ,
365
- ) ;
366
- }
367
- } ;
368
- const appDcOpenHandler = ( event : Event ) => {
369
- if ( ! this . #instanceTerminated) {
370
- this . #appDataConfig?. onOpen ?.( event ) ;
371
- this . #updateConnectedStatus( ) ;
372
- }
373
- } ;
374
- this . #appDataCh. onopen = appDcOpenHandler ;
375
-
376
- const createFatalAppDcHandler = ( type : string ) => ( event ?: Event ) => { // onerror might not pass event
377
- if ( ! this . #instanceTerminated) {
378
- if ( type === "close" && this . #appDataConfig?. onClose && event ) {
379
- this . #appDataConfig. onClose ( event ) ;
380
- }
381
- this . #terminateInstance( "failed" , `Application DataChannel ${ type } ` ) ;
382
- }
227
+ if ( ! appDataConfig ) {
228
+ appDataConfig = {
229
+ onMessage : ( ) => { } ,
383
230
} ;
384
- this . #appDataCh. onclose = createFatalAppDcHandler ( "closed" ) ;
385
- this . #appDataCh. onerror = createFatalAppDcHandler ( "error" ) ;
386
231
}
387
232
233
+ this . #appDataCh = this . #pc. createDataChannel (
234
+ "app::data" ,
235
+ appDataConfig . options ,
236
+ ) ;
237
+ this . #appDataCh. onopen = appDataConfig . onOpen || null ;
238
+ this . #appDataCh. onclose = appDataConfig . onClose || null ;
239
+
388
240
// Transceivers
389
241
this . #videoSender =
390
- peerConnection . addTransceiver ( "video" , { direction : "sendonly" } ) . sender ;
242
+ this . #pc . addTransceiver ( "video" , { direction : "sendonly" } ) . sender ;
391
243
this . #audioSender =
392
- peerConnection . addTransceiver ( "audio" , { direction : "sendonly" } ) . sender ;
393
- for ( let i = 0 ; i < this . # maxDownstreams; i ++ ) {
394
- const videoTransceiver = peerConnection . addTransceiver ( "video" , {
244
+ this . #pc . addTransceiver ( "audio" , { direction : "sendonly" } ) . sender ;
245
+ for ( let i = 0 ; i < maxDownstreams ; i ++ ) {
246
+ const videoTransceiver = this . #pc . addTransceiver ( "video" , {
395
247
direction : "recvonly" ,
396
248
} ) ;
397
249
if ( videoTransceiver . mid ) this . #videoRecvMids. push ( videoTransceiver . mid ) ;
398
- const audioTransceiver = peerConnection . addTransceiver ( "audio" , {
250
+ const audioTransceiver = this . #pc . addTransceiver ( "audio" , {
399
251
direction : "recvonly" ,
400
252
} ) ;
401
253
if ( audioTransceiver . mid ) this . #audioRecvMids. push ( audioTransceiver . mid ) ;
402
254
}
255
+ }
256
+
257
+ #terminateInstance(
258
+ finalStatus : "disconnected" | "failed" ,
259
+ message ?: string ,
260
+ ) : void {
261
+ if ( this . #instanceTerminated) {
262
+ return ;
263
+ }
264
+ this . #instanceTerminated = true ;
265
+ console . debug (
266
+ `PulsebeamClient: Terminating instance with status: ${ finalStatus } , message: ${ message || "N/A"
267
+ } `,
268
+ ) ;
269
+
270
+ this . localVideo . get ( ) ?. stop ( ) ;
271
+ this . localAudio . get ( ) ?. stop ( ) ;
272
+ this . localVideo . set ( null ) ;
273
+ this . localAudio . set ( null ) ;
274
+
275
+ Object . values ( this . remoteTracks . get ( ) ) . forEach ( ( remoteTrackInfo ) => {
276
+ remoteTrackInfo ?. track ?. stop ( ) ;
277
+ } ) ;
278
+ this . remoteTracks . set ( { } ) ;
279
+ this . availableTracks . set ( { } ) ;
280
+
281
+ this . #pc. getSenders ( ) . forEach ( ( sender ) => {
282
+ sender . track ?. stop ( ) ;
283
+ } ) ;
284
+ // No need to explicitly stop receiver tracks here, as they are managed by remoteTracks cleanup
285
+ this . #pc. close ( ) ;
286
+
287
+ this . #activeSubscriptions. clear ( ) ;
288
+ this . #usedMids. clear ( ) ;
289
+ this . #videoRecvMids = [ ] ;
290
+ this . #audioRecvMids = [ ] ;
291
+
292
+ if ( message ) {
293
+ this . errorMsg . set ( message ) ;
294
+ }
295
+ this . status . set ( finalStatus ) ;
296
+ console . warn (
297
+ "PulsebeamClient instance has been terminated and is no longer usable." ,
298
+ ) ;
299
+ }
300
+
301
+ #updateConnectedStatus( ) : void {
302
+ if ( this . #instanceTerminated || this . status . get ( ) !== "connecting" ) {
303
+ return ;
304
+ }
305
+
306
+ const pcConnected = this . #pc?. connectionState === "connected" ;
307
+ const rpcReady = this . #sfuRpcCh?. readyState === "open" ;
308
+
309
+ if ( pcConnected && rpcReady ) {
310
+ this . status . set ( "connected" ) ;
311
+ this . errorMsg . set ( null ) ; // Clear any transient errors from connecting phase
312
+ }
313
+ }
314
+
315
+ async connect ( room : string , participantId : string ) : Promise < void > {
316
+ if ( this . #instanceTerminated) {
317
+ const errorMessage =
318
+ "This client instance has been terminated and cannot be reused." ;
319
+ this . errorMsg . set ( errorMessage ) ;
320
+ console . error ( errorMessage ) ;
321
+ throw new Error ( errorMessage ) ; // More direct feedback to developer
322
+ }
323
+
324
+ if ( this . status . get ( ) !== "new" ) {
325
+ const errorMessage =
326
+ `Client can only connect when in "new" state. Current status: ${ this . status . get ( ) } . Create a new instance to reconnect.` ;
327
+ // Only set error if it's not already a terminal state from a previous attempt on this (now invalid) instance
328
+ if (
329
+ this . status . get ( ) !== "failed" && this . status . get ( ) !== "disconnected"
330
+ ) {
331
+ this . errorMsg . set ( errorMessage ) ;
332
+ }
333
+ console . warn ( errorMessage ) ;
334
+ return ; // Do not proceed
335
+ }
336
+
337
+ this . status . set ( "connecting" ) ;
338
+ this . errorMsg . set ( null ) ;
403
339
404
340
// Signaling
405
341
try {
406
- const offer = await peerConnection . createOffer ( ) ;
407
- await peerConnection . setLocalDescription ( offer ) ;
342
+ const offer = await this . #pc . createOffer ( ) ;
343
+ await this . #pc . setLocalDescription ( offer ) ;
408
344
const response = await fetch (
409
345
`${ this . #sfuUrl} ?room=${ room } &participant=${ participantId } ` ,
410
346
{
@@ -419,7 +355,7 @@ export class PulsebeamClient {
419
355
. text ( ) } `,
420
356
) ;
421
357
}
422
- await peerConnection . setRemoteDescription ( {
358
+ await this . #pc . setRemoteDescription ( {
423
359
type : "answer" ,
424
360
sdp : await response . text ( ) ,
425
361
} ) ;
0 commit comments