@stream-io/video-client 1.42.1 → 1.42.2

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.
@@ -1,5 +1,5 @@
1
1
  import { Dispatcher, IceTrickleBuffer } from './rtc';
2
- import { Error as SfuErrorEvent, JoinRequest, JoinResponse } from './gen/video/sfu/event/events';
2
+ import { JoinRequest, JoinResponse } from './gen/video/sfu/event/events';
3
3
  import { ICERestartRequest, SendAnswerRequest, SendStatsRequest, SetPublisherRequest, TrackMuteState, TrackSubscriptionDetails } from './gen/video/sfu/signal_rpc/signal';
4
4
  import { ICETrickle } from './gen/video/sfu/models/models';
5
5
  import { StreamClient } from './coordinator/connection/client';
@@ -170,8 +170,3 @@ export declare class StreamSfuClient {
170
170
  private keepAlive;
171
171
  private scheduleConnectionCheck;
172
172
  }
173
- export declare class SfuJoinError extends Error {
174
- errorEvent: SfuErrorEvent;
175
- unrecoverable: boolean;
176
- constructor(event: SfuErrorEvent);
177
- }
@@ -0,0 +1,7 @@
1
+ import { Error as SfuErrorEvent } from '../gen/video/sfu/event/events';
2
+ export declare class SfuJoinError extends Error {
3
+ errorEvent: SfuErrorEvent;
4
+ unrecoverable: boolean;
5
+ constructor(event: SfuErrorEvent);
6
+ static isJoinErrorCode(event: SfuErrorEvent): boolean;
7
+ }
@@ -0,0 +1 @@
1
+ export * from './SfuJoinError';
@@ -5358,6 +5358,12 @@ export interface JoinCallRequest {
5358
5358
  * @memberof JoinCallRequest
5359
5359
  */
5360
5360
  migrating_from?: string;
5361
+ /**
5362
+ * List of SFU IDs to exclude when picking a new SFU for the participant
5363
+ * @type {Array<string>}
5364
+ * @memberof JoinCallRequest
5365
+ */
5366
+ migrating_from_list?: Array<string>;
5361
5367
  /**
5362
5368
  *
5363
5369
  * @type {boolean}
@@ -18,6 +18,7 @@ export declare abstract class BasePeerConnection {
18
18
  protected readonly state: CallState;
19
19
  protected readonly dispatcher: Dispatcher;
20
20
  protected readonly clientPublishOptions?: ClientPublishOptions;
21
+ protected readonly tag: string;
21
22
  protected sfuClient: StreamSfuClient;
22
23
  private onReconnectionNeeded?;
23
24
  private readonly iceRestartDelay;
@@ -15,11 +15,45 @@ export type DispatchableMessage<K extends SfuEventKinds> = {
15
15
  [Key in K]: AllSfuEvents[Key];
16
16
  };
17
17
  };
18
+ /**
19
+ * Determines if a given event name belongs to the category of SFU events.
20
+ *
21
+ * @param eventName the name of the event to check.
22
+ * @returns true if the event name is an SFU event, otherwise false.
23
+ */
18
24
  export declare const isSfuEvent: (eventName: SfuEventKinds | EventTypes) => eventName is SfuEventKinds;
19
25
  export declare class Dispatcher {
20
26
  private readonly logger;
21
27
  private subscribers;
28
+ /**
29
+ * Dispatch an event to all subscribers.
30
+ *
31
+ * @param message the event payload to dispatch.
32
+ * @param tag for scoping events to a specific tag. Use `*` dispatch to every tag.
33
+ */
22
34
  dispatch: <K extends SfuEventKinds>(message: DispatchableMessage<K>, tag?: string) => void;
23
- on: <E extends keyof AllSfuEvents>(eventName: E, fn: CallEventListener<E>) => () => void;
24
- off: <E extends keyof AllSfuEvents>(eventName: E, fn: CallEventListener<E>) => void;
35
+ /**
36
+ * Emit an event to a list of listeners.
37
+ *
38
+ * @param payload the event payload to emit.
39
+ * @param listeners the list of listeners to emit the event to.
40
+ */
41
+ emit: (payload: any, listeners?: CallEventListener<any>[]) => void;
42
+ /**
43
+ * Subscribe to an event.
44
+ *
45
+ * @param eventName the name of the event to subscribe to.
46
+ * @param tag for scoping events to a specific tag. Use `*` dispatch to every tag.
47
+ * @param fn the callback function to invoke when the event is emitted.
48
+ * @returns a function that can be called to unsubscribe from the event.
49
+ */
50
+ on: <E extends keyof AllSfuEvents>(eventName: E, tag: string, fn: CallEventListener<E>) => () => void;
51
+ /**
52
+ * Unsubscribe from an event.
53
+ *
54
+ * @param eventName the name of the event to unsubscribe from.
55
+ * @param tag for scoping events to a specific tag. Use `*` dispatch to every tag.
56
+ * @param fn the callback function to remove from the event listeners.
57
+ */
58
+ off: <E extends keyof AllSfuEvents>(eventName: E, tag: string, fn: CallEventListener<E>) => void;
25
59
  }
package/index.ts CHANGED
@@ -14,6 +14,7 @@ export * from './src/CallType';
14
14
  export * from './src/StreamVideoClient';
15
15
  export * from './src/StreamSfuClient';
16
16
  export * from './src/devices';
17
+ export * from './src/errors';
17
18
  export * from './src/store';
18
19
  export * from './src/sorting';
19
20
  export * from './src/helpers/client-details';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.42.1",
3
+ "version": "1.42.2",
4
4
  "main": "dist/index.cjs.js",
5
5
  "module": "dist/index.es.js",
6
6
  "browser": "dist/index.browser.es.js",
package/src/Call.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { SfuJoinError, StreamSfuClient } from './StreamSfuClient';
1
+ import { StreamSfuClient } from './StreamSfuClient';
2
+ import { SfuJoinError } from './errors';
2
3
  import {
3
4
  BasePeerConnectionOpts,
4
5
  Dispatcher,
@@ -561,7 +562,7 @@ export class Call {
561
562
  fn: CallEventListener<E>,
562
563
  ) => {
563
564
  if (isSfuEvent(eventName)) {
564
- return this.dispatcher.on(eventName, fn);
565
+ return this.dispatcher.on(eventName, '*', fn);
565
566
  }
566
567
 
567
568
  const offHandler = this.streamClient.on(eventName, (e) => {
@@ -589,7 +590,7 @@ export class Call {
589
590
  fn: CallEventListener<E>,
590
591
  ) => {
591
592
  if (isSfuEvent(eventName)) {
592
- return this.dispatcher.off(eventName, fn);
593
+ return this.dispatcher.off(eventName, '*', fn);
593
594
  }
594
595
 
595
596
  // unsubscribe from the stream client event by using the 'off' reference
@@ -918,6 +919,7 @@ export class Call {
918
919
  this.logger.trace(`Joining call (${attempt})`, this.cid);
919
920
  await this.doJoin(data);
920
921
  delete joinData.migrating_from;
922
+ delete joinData.migrating_from_list;
921
923
  break;
922
924
  } catch (err) {
923
925
  this.logger.warn(`Failed to join call (${attempt})`, this.cid);
@@ -931,11 +933,17 @@ export class Call {
931
933
  throw err;
932
934
  }
933
935
 
936
+ // immediately switch to a different SFU in case of recoverable join error
937
+ const switchSfu =
938
+ err instanceof SfuJoinError &&
939
+ SfuJoinError.isJoinErrorCode(err.errorEvent);
940
+
934
941
  const sfuId = this.credentials?.server.edge_name || '';
935
942
  const failures = (sfuJoinFailures.get(sfuId) || 0) + 1;
936
943
  sfuJoinFailures.set(sfuId, failures);
937
- if (failures >= 2) {
944
+ if (switchSfu || failures >= 2) {
938
945
  joinData.migrating_from = sfuId;
946
+ joinData.migrating_from_list = Array.from(sfuJoinFailures.keys());
939
947
  }
940
948
 
941
949
  if (attempt === maxJoinRetries - 1) {
@@ -1611,11 +1619,16 @@ export class Call {
1611
1619
 
1612
1620
  try {
1613
1621
  const currentSfu = currentSfuClient.edgeName;
1614
- await this.doJoin({ ...this.joinCallData, migrating_from: currentSfu });
1622
+ await this.doJoin({
1623
+ ...this.joinCallData,
1624
+ migrating_from: currentSfu,
1625
+ migrating_from_list: [currentSfu],
1626
+ });
1615
1627
  } finally {
1616
1628
  // cleanup the migration_from field after the migration is complete or failed
1617
1629
  // as we don't want to keep dirty data in the join call data
1618
1630
  delete this.joinCallData?.migrating_from;
1631
+ delete this.joinCallData?.migrating_from_list;
1619
1632
  }
1620
1633
 
1621
1634
  await this.restorePublishedTracks();
@@ -1660,6 +1673,10 @@ export class Call {
1660
1673
  // handles the "error" event, through which the SFU can request a reconnect
1661
1674
  const unregisterOnError = this.on('error', (e) => {
1662
1675
  const { reconnectStrategy: strategy, error } = e;
1676
+ // SFU_FULL is a join error, and when emitted, although it specifies a
1677
+ // `migrate` strategy, we should actually perform a REJOIN to a new SFU.
1678
+ // This is now handled separately in the `call.join()` method.
1679
+ if (SfuJoinError.isJoinErrorCode(e)) return;
1663
1680
  if (strategy === WebsocketReconnectStrategy.UNSPECIFIED) return;
1664
1681
  if (strategy === WebsocketReconnectStrategy.DISCONNECT) {
1665
1682
  this.leave({ message: 'SFU instructed to disconnect' }).catch((err) => {
@@ -14,7 +14,6 @@ import {
14
14
  SfuEventKinds,
15
15
  } from './rtc';
16
16
  import {
17
- Error as SfuErrorEvent,
18
17
  JoinRequest,
19
18
  JoinResponse,
20
19
  SfuRequest,
@@ -27,10 +26,7 @@ import {
27
26
  TrackMuteState,
28
27
  TrackSubscriptionDetails,
29
28
  } from './gen/video/sfu/signal_rpc/signal';
30
- import {
31
- ICETrickle,
32
- WebsocketReconnectStrategy,
33
- } from './gen/video/sfu/models/models';
29
+ import { ICETrickle } from './gen/video/sfu/models/models';
34
30
  import { StreamClient } from './coordinator/connection/client';
35
31
  import { generateUUIDv4 } from './coordinator/connection/utils';
36
32
  import { Credentials } from './gen/coordinator';
@@ -43,6 +39,7 @@ import {
43
39
  } from './helpers/promise';
44
40
  import { getTimers } from './timers';
45
41
  import { Tracer, TraceSlice } from './stats';
42
+ import { SfuJoinError } from './errors';
46
43
 
47
44
  export type StreamSfuClientConstructor = {
48
45
  /**
@@ -250,8 +247,8 @@ export class StreamSfuClient {
250
247
  // In that case, those events (ICE candidates) need to be buffered
251
248
  // and later added to the appropriate PeerConnection
252
249
  // once the remoteDescription is known and set.
253
- this.unsubscribeIceTrickle = dispatcher.on('iceTrickle', (iceTrickle) => {
254
- this.iceTrickleBuffer.push(iceTrickle);
250
+ this.unsubscribeIceTrickle = dispatcher.on('iceTrickle', tag, (t) => {
251
+ this.iceTrickleBuffer.push(t);
255
252
  });
256
253
 
257
254
  // listen to network changes to handle offline state
@@ -504,6 +501,7 @@ export class StreamSfuClient {
504
501
  const task = (this.migrationTask = promiseWithResolvers());
505
502
  const unsubscribe = this.dispatcher.on(
506
503
  'participantMigrationComplete',
504
+ this.tag,
507
505
  () => {
508
506
  unsubscribe();
509
507
  clearTimeout(this.migrateAwayTimeout);
@@ -541,27 +539,40 @@ export class StreamSfuClient {
541
539
  const current = this.joinResponseTask;
542
540
 
543
541
  let timeoutId: NodeJS.Timeout | undefined = undefined;
544
- const unsubscribeJoinErrorEvents = this.dispatcher.on('error', (event) => {
545
- const { error, reconnectStrategy } = event;
546
- if (!error) return;
547
- if (reconnectStrategy === WebsocketReconnectStrategy.DISCONNECT) {
548
- clearTimeout(timeoutId);
549
- unsubscribe?.();
550
- unsubscribeJoinErrorEvents();
551
- current.reject(new SfuJoinError(event));
552
- }
553
- });
554
- const unsubscribe = this.dispatcher.on('joinResponse', (joinResponse) => {
542
+ let unsubscribeJoinResponse: (() => void) | undefined = undefined;
543
+ let unsubscribeJoinErrorEvents: (() => void) | undefined = undefined;
544
+
545
+ const cleanupJoinSubscriptions = () => {
555
546
  clearTimeout(timeoutId);
556
- unsubscribe();
557
- unsubscribeJoinErrorEvents();
558
- this.keepAlive();
559
- current.resolve(joinResponse);
560
- });
547
+ timeoutId = undefined;
548
+ unsubscribeJoinErrorEvents?.();
549
+ unsubscribeJoinErrorEvents = undefined;
550
+ unsubscribeJoinResponse?.();
551
+ unsubscribeJoinResponse = undefined;
552
+ };
553
+
554
+ unsubscribeJoinErrorEvents = this.dispatcher.on(
555
+ 'error',
556
+ this.tag,
557
+ (event) => {
558
+ if (SfuJoinError.isJoinErrorCode(event)) {
559
+ cleanupJoinSubscriptions();
560
+ current.reject(new SfuJoinError(event));
561
+ }
562
+ },
563
+ );
564
+ unsubscribeJoinResponse = this.dispatcher.on(
565
+ 'joinResponse',
566
+ this.tag,
567
+ (joinResponse) => {
568
+ cleanupJoinSubscriptions();
569
+ this.keepAlive();
570
+ current.resolve(joinResponse);
571
+ },
572
+ );
561
573
 
562
574
  timeoutId = setTimeout(() => {
563
- unsubscribe();
564
- unsubscribeJoinErrorEvents();
575
+ cleanupJoinSubscriptions();
565
576
  const message = `Waiting for "joinResponse" has timed out after ${this.joinResponseTimeout}ms`;
566
577
  this.tracer?.trace('joinRequestTimeout', message);
567
578
  current.reject(new Error(message));
@@ -647,15 +658,3 @@ export class StreamSfuClient {
647
658
  }, this.unhealthyTimeoutInMs);
648
659
  };
649
660
  }
650
-
651
- export class SfuJoinError extends Error {
652
- errorEvent: SfuErrorEvent;
653
- unrecoverable: boolean;
654
-
655
- constructor(event: SfuErrorEvent) {
656
- super(event.error?.message || 'Join Error');
657
- this.errorEvent = event;
658
- this.unrecoverable =
659
- event.reconnectStrategy === WebsocketReconnectStrategy.DISCONNECT;
660
- }
661
- }
@@ -0,0 +1,26 @@
1
+ import { Error as SfuErrorEvent } from '../gen/video/sfu/event/events';
2
+ import {
3
+ ErrorCode,
4
+ WebsocketReconnectStrategy,
5
+ } from '../gen/video/sfu/models/models';
6
+
7
+ export class SfuJoinError extends Error {
8
+ errorEvent: SfuErrorEvent;
9
+ unrecoverable: boolean;
10
+
11
+ constructor(event: SfuErrorEvent) {
12
+ super(event.error?.message || 'Join Error');
13
+ this.errorEvent = event;
14
+ this.unrecoverable =
15
+ event.reconnectStrategy === WebsocketReconnectStrategy.DISCONNECT;
16
+ }
17
+
18
+ static isJoinErrorCode(event: SfuErrorEvent): boolean {
19
+ const code = event.error?.code;
20
+ return (
21
+ code === ErrorCode.SFU_FULL ||
22
+ code === ErrorCode.SFU_SHUTTING_DOWN ||
23
+ code === ErrorCode.CALL_PARTICIPANT_LIMIT_REACHED
24
+ );
25
+ }
26
+ }
@@ -0,0 +1 @@
1
+ export * from './SfuJoinError';
@@ -18,7 +18,7 @@ export const watchConnectionQualityChanged = (
18
18
  dispatcher: Dispatcher,
19
19
  state: CallState,
20
20
  ) => {
21
- return dispatcher.on('connectionQualityChanged', (e) => {
21
+ return dispatcher.on('connectionQualityChanged', '*', (e) => {
22
22
  const { connectionQualityUpdates } = e;
23
23
  if (!connectionQualityUpdates) return;
24
24
  state.updateParticipants(
@@ -44,7 +44,7 @@ export const watchParticipantCountChanged = (
44
44
  dispatcher: Dispatcher,
45
45
  state: CallState,
46
46
  ) => {
47
- return dispatcher.on('healthCheckResponse', (e) => {
47
+ return dispatcher.on('healthCheckResponse', '*', (e) => {
48
48
  const { participantCount } = e;
49
49
  if (participantCount) {
50
50
  state.setParticipantCount(participantCount.total);
@@ -54,7 +54,7 @@ export const watchParticipantCountChanged = (
54
54
  };
55
55
 
56
56
  export const watchLiveEnded = (dispatcher: Dispatcher, call: Call) => {
57
- return dispatcher.on('error', (e) => {
57
+ return dispatcher.on('error', '*', (e) => {
58
58
  if (e.error && e.error.code !== ErrorCode.LIVE_ENDED) return;
59
59
 
60
60
  call.state.setBackstage(true);
@@ -70,7 +70,7 @@ export const watchLiveEnded = (dispatcher: Dispatcher, call: Call) => {
70
70
  * Watches and logs the errors reported by the currently connected SFU.
71
71
  */
72
72
  export const watchSfuErrorReports = (dispatcher: Dispatcher) => {
73
- return dispatcher.on('error', (e) => {
73
+ return dispatcher.on('error', '*', (e) => {
74
74
  if (!e.error) return;
75
75
  const logger = videoLoggerSystem.getLogger('SfuClient');
76
76
  const { error, reconnectStrategy } = e;
@@ -9,7 +9,7 @@ export const watchDominantSpeakerChanged = (
9
9
  dispatcher: Dispatcher,
10
10
  state: CallState,
11
11
  ) => {
12
- return dispatcher.on('dominantSpeakerChanged', (e) => {
12
+ return dispatcher.on('dominantSpeakerChanged', '*', (e) => {
13
13
  const { sessionId } = e;
14
14
  if (sessionId === state.dominantSpeaker?.sessionId) return;
15
15
  state.setParticipants((participants) =>
@@ -41,7 +41,7 @@ export const watchAudioLevelChanged = (
41
41
  dispatcher: Dispatcher,
42
42
  state: CallState,
43
43
  ) => {
44
- return dispatcher.on('audioLevelChanged', (e) => {
44
+ return dispatcher.on('audioLevelChanged', '*', (e) => {
45
45
  const { audioLevels } = e;
46
46
  state.updateParticipants(
47
47
  audioLevels.reduce<StreamVideoParticipantPatches>((patches, current) => {
@@ -5362,6 +5362,12 @@ export interface JoinCallRequest {
5362
5362
  * @memberof JoinCallRequest
5363
5363
  */
5364
5364
  migrating_from?: string;
5365
+ /**
5366
+ * List of SFU IDs to exclude when picking a new SFU for the participant
5367
+ * @type {Array<string>}
5368
+ * @memberof JoinCallRequest
5369
+ */
5370
+ migrating_from_list?: Array<string>;
5365
5371
  /**
5366
5372
  *
5367
5373
  * @type {boolean}
@@ -27,6 +27,7 @@ export abstract class BasePeerConnection {
27
27
  protected readonly state: CallState;
28
28
  protected readonly dispatcher: Dispatcher;
29
29
  protected readonly clientPublishOptions?: ClientPublishOptions;
30
+ protected readonly tag: string;
30
31
  protected sfuClient: StreamSfuClient;
31
32
 
32
33
  private onReconnectionNeeded?: OnReconnectionNeeded;
@@ -67,6 +68,7 @@ export abstract class BasePeerConnection {
67
68
  this.dispatcher = dispatcher;
68
69
  this.iceRestartDelay = iceRestartDelay;
69
70
  this.clientPublishOptions = clientPublishOptions;
71
+ this.tag = tag;
70
72
  this.onReconnectionNeeded = onReconnectionNeeded;
71
73
  this.logger = videoLoggerSystem.getLogger(
72
74
  peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher',
@@ -162,7 +164,7 @@ export abstract class BasePeerConnection {
162
164
  fn: CallEventListener<E>,
163
165
  ): void => {
164
166
  this.subscriptions.push(
165
- this.dispatcher.on(event, (e) => {
167
+ this.dispatcher.on(event, this.tag, (e) => {
166
168
  const lockKey = `pc.${this.lock}.${event}`;
167
169
  withoutConcurrency(lockKey, async () => fn(e)).catch((err) => {
168
170
  if (this.isDisposed) return;
@@ -46,53 +46,95 @@ const sfuEventKinds: Record<SfuEventKinds, undefined> = {
46
46
  inboundStateNotification: undefined,
47
47
  };
48
48
 
49
+ /**
50
+ * Determines if a given event name belongs to the category of SFU events.
51
+ *
52
+ * @param eventName the name of the event to check.
53
+ * @returns true if the event name is an SFU event, otherwise false.
54
+ */
49
55
  export const isSfuEvent = (
50
56
  eventName: SfuEventKinds | EventTypes,
51
57
  ): eventName is SfuEventKinds => {
52
58
  return Object.prototype.hasOwnProperty.call(sfuEventKinds, eventName);
53
59
  };
54
60
 
61
+ type TaggedHandler = { [tag: string]: CallEventListener<any>[] | undefined };
62
+
55
63
  export class Dispatcher {
56
64
  private readonly logger = videoLoggerSystem.getLogger('Dispatcher');
57
- private subscribers: Partial<
58
- Record<SfuEventKinds, CallEventListener<any>[] | undefined>
59
- > = {};
65
+ private subscribers: Partial<Record<SfuEventKinds, TaggedHandler>> = {};
60
66
 
67
+ /**
68
+ * Dispatch an event to all subscribers.
69
+ *
70
+ * @param message the event payload to dispatch.
71
+ * @param tag for scoping events to a specific tag. Use `*` dispatch to every tag.
72
+ */
61
73
  dispatch = <K extends SfuEventKinds>(
62
74
  message: DispatchableMessage<K>,
63
- tag: string = '0',
75
+ tag: string = '*',
64
76
  ) => {
65
77
  const eventKind = message.eventPayload.oneofKind;
66
78
  if (!eventKind) return;
67
79
  const payload = message.eventPayload[eventKind];
68
80
  this.logger.debug(`Dispatching ${eventKind}, tag=${tag}`, payload);
69
- const listeners = this.subscribers[eventKind];
70
- if (!listeners) return;
71
- for (const fn of listeners) {
81
+ const handlers = this.subscribers[eventKind];
82
+ if (!handlers) return;
83
+ this.emit(payload, handlers[tag]);
84
+ if (tag !== '*') this.emit(payload, handlers['*']);
85
+ };
86
+
87
+ /**
88
+ * Emit an event to a list of listeners.
89
+ *
90
+ * @param payload the event payload to emit.
91
+ * @param listeners the list of listeners to emit the event to.
92
+ */
93
+ emit = (payload: any, listeners: CallEventListener<any>[] = []) => {
94
+ for (const listener of listeners) {
72
95
  try {
73
- fn(payload);
96
+ listener(payload);
74
97
  } catch (e) {
75
98
  this.logger.warn('Listener failed with error', e);
76
99
  }
77
100
  }
78
101
  };
79
102
 
103
+ /**
104
+ * Subscribe to an event.
105
+ *
106
+ * @param eventName the name of the event to subscribe to.
107
+ * @param tag for scoping events to a specific tag. Use `*` dispatch to every tag.
108
+ * @param fn the callback function to invoke when the event is emitted.
109
+ * @returns a function that can be called to unsubscribe from the event.
110
+ */
80
111
  on = <E extends keyof AllSfuEvents>(
81
112
  eventName: E,
113
+ tag: string,
82
114
  fn: CallEventListener<E>,
83
115
  ) => {
84
- (this.subscribers[eventName] ??= []).push(fn as never);
116
+ const bucket = (this.subscribers[eventName] ??= {} as TaggedHandler);
117
+ (bucket[tag] ??= []).push(fn);
85
118
  return () => {
86
- this.off(eventName, fn);
119
+ this.off(eventName, tag, fn);
87
120
  };
88
121
  };
89
122
 
123
+ /**
124
+ * Unsubscribe from an event.
125
+ *
126
+ * @param eventName the name of the event to unsubscribe from.
127
+ * @param tag for scoping events to a specific tag. Use `*` dispatch to every tag.
128
+ * @param fn the callback function to remove from the event listeners.
129
+ */
90
130
  off = <E extends keyof AllSfuEvents>(
91
131
  eventName: E,
132
+ tag: string,
92
133
  fn: CallEventListener<E>,
93
134
  ) => {
94
- this.subscribers[eventName] = (this.subscribers[eventName] || []).filter(
95
- (f) => f !== fn,
96
- );
135
+ const bucket = this.subscribers[eventName];
136
+ const listeners = bucket?.[tag];
137
+ if (!listeners) return;
138
+ bucket[tag] = listeners.filter((f) => f !== fn);
97
139
  };
98
140
  }
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { DispatchableMessage, Dispatcher } from '../Dispatcher';
3
+
4
+ describe('Dispatcher', () => {
5
+ it('routes events to tag listeners and wildcard listeners', () => {
6
+ const dispatcher = new Dispatcher();
7
+ const taggedListener = vi.fn();
8
+ const wildcardListener = vi.fn();
9
+
10
+ dispatcher.on('healthCheckResponse', 'tag-1', taggedListener);
11
+ dispatcher.on('healthCheckResponse', '*', wildcardListener);
12
+
13
+ const message: DispatchableMessage<'healthCheckResponse'> = {
14
+ eventPayload: {
15
+ oneofKind: 'healthCheckResponse',
16
+ healthCheckResponse: {} as never,
17
+ },
18
+ };
19
+
20
+ dispatcher.dispatch(message, 'tag-1');
21
+ expect(taggedListener).toHaveBeenCalledTimes(1);
22
+ expect(wildcardListener).toHaveBeenCalledTimes(1);
23
+
24
+ dispatcher.dispatch(message, 'tag-2');
25
+ expect(taggedListener).toHaveBeenCalledTimes(1);
26
+ expect(wildcardListener).toHaveBeenCalledTimes(2);
27
+ });
28
+ });
@@ -180,6 +180,7 @@ describe('Publisher', () => {
180
180
  },
181
181
  },
182
182
  }) as DispatchableMessage<'changePublishQuality'>,
183
+ 'test',
183
184
  );
184
185
  expect(publisher['changePublishQuality']).toHaveBeenCalled();
185
186
  });
@@ -193,6 +194,7 @@ describe('Publisher', () => {
193
194
  changePublishOptions: { publishOptions: [], reason: 'test' },
194
195
  },
195
196
  }) as DispatchableMessage<'changePublishOptions'>,
197
+ 'test',
196
198
  );
197
199
  expect(publisher['syncPublishOptions']).toHaveBeenCalled();
198
200
  });
@@ -210,6 +212,7 @@ describe('Publisher', () => {
210
212
  },
211
213
  },
212
214
  }) as DispatchableMessage<'iceRestart'>,
215
+ 'test',
213
216
  );
214
217
  expect(publisher.restartIce).toHaveBeenCalled();
215
218
  });
@@ -36,7 +36,7 @@ describe('Subscriber', () => {
36
36
  sessionId: 'sessionId',
37
37
  streamClient: new StreamClient('abc'),
38
38
  cid: 'test:123',
39
- tag: 'logTag',
39
+ tag: 'test',
40
40
  credentials: {
41
41
  server: {
42
42
  url: 'https://getstream.io/',
@@ -255,6 +255,7 @@ describe('Subscriber', () => {
255
255
  subscriberOffer,
256
256
  },
257
257
  }) as DispatchableMessage<'subscriberOffer'>,
258
+ 'test',
258
259
  );
259
260
 
260
261
  // @ts-expect-error - private method