@@ -11,6 +11,8 @@ import {
1111 TrackUnpublishedPayload ,
1212} from "./sfu" ;
1313
14+ const MAX_DOWNSTREAMS = 9 ;
15+
1416export type ClientStatus =
1517 | "new"
1618 | "connecting"
@@ -40,16 +42,15 @@ export interface AppDataChannelConfig {
4042}
4143
4244export class PulsebeamClient {
43- #pc: RTCPeerConnection | null = null ;
44- #sfuRpcCh: RTCDataChannel | null = null ;
45- #appDataCh: RTCDataChannel | null = null ;
46-
4745 readonly #sfuUrl: string ;
48- readonly #maxDownstreams: number ;
49- readonly #appDataConfig?: AppDataChannelConfig ;
5046
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+
5354 #videoRecvMids: string [ ] = [ ] ;
5455 #audioRecvMids: string [ ] = [ ] ;
5556 #usedMids = new Set < string > ( ) ;
@@ -75,139 +76,13 @@ export class PulsebeamClient {
7576 appDataConfig ?: AppDataChannelConfig ,
7677 ) {
7778 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 ,
16282 ) ;
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 ) ;
20583
20684 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 = ( ) => {
21186 if ( this . #instanceTerminated || ! this . #pc) return ; // Guard
21287 const connectionState = this . #pc. connectionState ;
21388 console . debug ( `PeerConnection state changed: ${ connectionState } ` ) ;
@@ -225,7 +100,7 @@ export class PulsebeamClient {
225100 }
226101 } ;
227102
228- peerConnection . ontrack = ( event : RTCTrackEvent ) => {
103+ this . #pc . ontrack = ( event : RTCTrackEvent ) => {
229104 if ( this . #instanceTerminated) return ;
230105 const mid = event . transceiver ?. mid ;
231106 const track = event . track ;
@@ -269,7 +144,7 @@ export class PulsebeamClient {
269144 } ;
270145
271146 // SFU RPC DataChannel
272- this . #sfuRpcCh = peerConnection . createDataChannel ( "pulsebeam::rpc" ) ;
147+ this . #sfuRpcCh = this . #pc . createDataChannel ( "pulsebeam::rpc" ) ;
273148 this . #sfuRpcCh. binaryType = "arraybuffer" ;
274149 this . #sfuRpcCh. onopen = ( ) => {
275150 if ( ! this . #instanceTerminated) this . #updateConnectedStatus( ) ;
@@ -349,62 +224,123 @@ export class PulsebeamClient {
349224 this . #sfuRpcCh. onclose = createFatalRpcHandler ( "closed" ) ;
350225 this . #sfuRpcCh. onerror = createFatalRpcHandler ( "error" ) ;
351226
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 : ( ) => { } ,
383230 } ;
384- this . #appDataCh. onclose = createFatalAppDcHandler ( "closed" ) ;
385- this . #appDataCh. onerror = createFatalAppDcHandler ( "error" ) ;
386231 }
387232
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+
388240 // Transceivers
389241 this . #videoSender =
390- peerConnection . addTransceiver ( "video" , { direction : "sendonly" } ) . sender ;
242+ this . #pc . addTransceiver ( "video" , { direction : "sendonly" } ) . sender ;
391243 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" , {
395247 direction : "recvonly" ,
396248 } ) ;
397249 if ( videoTransceiver . mid ) this . #videoRecvMids. push ( videoTransceiver . mid ) ;
398- const audioTransceiver = peerConnection . addTransceiver ( "audio" , {
250+ const audioTransceiver = this . #pc . addTransceiver ( "audio" , {
399251 direction : "recvonly" ,
400252 } ) ;
401253 if ( audioTransceiver . mid ) this . #audioRecvMids. push ( audioTransceiver . mid ) ;
402254 }
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 ) ;
403339
404340 // Signaling
405341 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 ) ;
408344 const response = await fetch (
409345 `${ this . #sfuUrl} ?room=${ room } &participant=${ participantId } ` ,
410346 {
@@ -419,7 +355,7 @@ export class PulsebeamClient {
419355 . text ( ) } `,
420356 ) ;
421357 }
422- await peerConnection . setRemoteDescription ( {
358+ await this . #pc . setRemoteDescription ( {
423359 type : "answer" ,
424360 sdp : await response . text ( ) ,
425361 } ) ;
0 commit comments