@webex/internal-plugin-llm 3.11.0 → 3.12.0-auth-prejoin-fetch.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.
package/src/llm.ts CHANGED
@@ -2,9 +2,15 @@
2
2
 
3
3
  import Mercury from '@webex/internal-plugin-mercury';
4
4
 
5
- import {LLM} from './constants';
6
5
  // eslint-disable-next-line no-unused-vars
7
- import {ILLMChannel} from './llm.types';
6
+ import {
7
+ LLM,
8
+ DATA_CHANNEL_WITH_JWT_TOKEN,
9
+ AWARE_DATA_CHANNEL,
10
+ SUBSCRIPTION_AWARE_SUBCHANNELS_PARAM,
11
+ LLM_DEFAULT_SESSION,
12
+ } from './constants';
13
+ import {ILLMChannel, DataChannelTokenType, RegisterAndConnectTiming} from './llm.types';
8
14
 
9
15
  export const config = {
10
16
  llm: {
@@ -42,90 +48,589 @@ export const config = {
42
48
  */
43
49
  export default class LLMChannel extends (Mercury as any) implements ILLMChannel {
44
50
  namespace = LLM;
45
-
51
+ defaultSessionId = LLM_DEFAULT_SESSION;
46
52
  /**
47
- * If the LLM plugin has been registered and listening
48
- * @instance
49
- * @type {Boolean}
50
- * @public
53
+ * Map to store connection-specific data for multiple LLM connections
54
+ * Key: sessionId
55
+ * @private
56
+ * @type {Map<string, {webSocketUrl?: string; binding?: string; locusUrl?: string; datachannelUrl?: string}>}
51
57
  */
58
+ private connections: Map<
59
+ string,
60
+ {
61
+ webSocketUrl?: string;
62
+ binding?: string;
63
+ locusUrl?: string;
64
+ datachannelUrl?: string;
65
+ ownerMeetingId?: string;
66
+ refreshHandler?: () => Promise<{
67
+ body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType};
68
+ }>;
69
+ }
70
+ > = new Map();
52
71
 
53
- private webSocketUrl?: string;
54
-
55
- private binding?: string;
56
-
57
- private locusUrl?: string;
58
-
59
- private datachannelUrl?: string;
72
+ // Session-keyed token cache is intentionally decoupled from connection state.
73
+ // Disconnecting a socket session must not implicitly wipe token cache.
74
+ private datachannelTokens: Record<string, string | undefined> = {
75
+ [DataChannelTokenType.Default]: undefined,
76
+ [DataChannelTokenType.PracticeSession]: undefined,
77
+ };
60
78
 
61
79
  /**
62
80
  * Register to the websocket
63
81
  * @param {string} llmSocketUrl
82
+ * @param {string} datachannelToken
83
+ * @param {string} sessionId - Connection identifier
64
84
  * @returns {Promise<void>}
65
85
  */
66
- private register = (llmSocketUrl: string): Promise<void> =>
67
- this.request({
86
+ private register = async (
87
+ llmSocketUrl: string,
88
+ datachannelToken?: string,
89
+ sessionId: string = LLM_DEFAULT_SESSION
90
+ ): Promise<void> => {
91
+ const isDataChannelTokenEnabled = await this.isDataChannelTokenEnabled();
92
+
93
+ return this.request({
68
94
  method: 'POST',
69
95
  url: llmSocketUrl,
70
96
  body: {deviceUrl: this.webex.internal.device.url},
97
+ headers:
98
+ isDataChannelTokenEnabled && datachannelToken
99
+ ? {'Data-Channel-Auth-Token': datachannelToken}
100
+ : {},
71
101
  })
72
102
  .then((res: {body: {webSocketUrl: string; binding: string}}) => {
73
- this.webSocketUrl = res.body.webSocketUrl;
74
- this.binding = res.body.binding;
103
+ // Get or create connection data
104
+ const sessionData = this.connections.get(sessionId) || {};
105
+ sessionData.webSocketUrl = res.body.webSocketUrl;
106
+ sessionData.binding = res.body.binding;
107
+ this.connections.set(sessionId, sessionData);
75
108
  })
76
109
  .catch((error: any) => {
77
- this.logger.error(`Error connecting to websocket: ${error}`);
110
+ this.logger.error(`Error connecting to websocket for ${sessionId}: ${error}`);
78
111
  throw error;
79
112
  });
113
+ };
80
114
 
81
115
  /**
82
116
  * Register and connect to the websocket
83
117
  * @param {string} locusUrl
84
118
  * @param {string} datachannelUrl
119
+ * @param {string} datachannelToken
120
+ * @param {string} sessionId - Connection identifier
85
121
  * @returns {Promise<void>}
86
122
  */
87
- public registerAndConnect = (locusUrl: string, datachannelUrl: string): Promise<void> =>
88
- this.register(datachannelUrl).then(() => {
123
+ public registerAndConnect = (
124
+ locusUrl: string,
125
+ datachannelUrl: string,
126
+ datachannelToken?: string,
127
+ sessionId: string = LLM_DEFAULT_SESSION
128
+ ): Promise<RegisterAndConnectTiming | undefined> => {
129
+ const registerStart = performance.now();
130
+
131
+ // Pre-populate locusUrl and datachannelUrl before register() fires the
132
+ // HTTP POST, so that any token refresh triggered during registration can
133
+ // be routed via connections without falling back to a locusInfo URL scan.
134
+ if (locusUrl && datachannelUrl) {
135
+ const sessionData = this.connections.get(sessionId) || {};
136
+ sessionData.locusUrl = locusUrl;
137
+ sessionData.datachannelUrl = datachannelUrl;
138
+ this.connections.set(sessionId, sessionData);
139
+ }
140
+
141
+ return this.register(datachannelUrl, datachannelToken, sessionId).then(async () => {
89
142
  if (!locusUrl || !datachannelUrl) return undefined;
90
- this.locusUrl = locusUrl;
91
- this.datachannelUrl = datachannelUrl;
92
- this.connect(this.webSocketUrl);
143
+
144
+ const clientLLMDatachannelResponseTime = Math.round(performance.now() - registerStart);
145
+
146
+ // locusUrl and datachannelUrl were pre-populated before register(); here
147
+ // we only need to read the existing session data to get webSocketUrl/binding
148
+ // that register() filled in.
149
+ const sessionData = this.connections.get(sessionId) || {};
150
+
151
+ if (!sessionData.webSocketUrl) {
152
+ // register() succeeded but the response carried no websocket URL. Attach the measured
153
+ // datachannel time so callers don't misreport a registration that completed as time 0.
154
+ const error: any = new Error(`LLM registration for ${sessionId} returned no websocket URL`);
155
+ error.timing = {clientLLMDatachannelResponseTime};
156
+ throw error;
157
+ }
158
+
159
+ const isDataChannelTokenEnabled = await this.isDataChannelTokenEnabled();
160
+
161
+ const connectUrl = isDataChannelTokenEnabled
162
+ ? LLMChannel.buildUrlWithAwareSubchannels(sessionData.webSocketUrl, AWARE_DATA_CHANNEL)
163
+ : sessionData.webSocketUrl;
164
+
165
+ const connectStart = performance.now();
166
+
167
+ try {
168
+ await this.connect(connectUrl, sessionId);
169
+ } catch (error) {
170
+ // register() succeeded; only connect() failed. Attach the measured datachannel time so
171
+ // callers don't misreport a websocket failure as a registration that never completed.
172
+ // @ts-ignore
173
+ error.timing = {clientLLMDatachannelResponseTime};
174
+ throw error;
175
+ }
176
+
177
+ const clientLLMWebSocketConnectTime = Math.round(performance.now() - connectStart);
178
+
179
+ return {clientLLMDatachannelResponseTime, clientLLMWebSocketConnectTime};
93
180
  });
181
+ };
94
182
 
95
183
  /**
96
184
  * Tells if LLM socket is connected
185
+ * @param {string} sessionId - Connection identifier
97
186
  * @returns {boolean} connected
98
187
  */
99
- public isConnected = (): boolean => this.connected;
188
+ public isConnected = (sessionId = LLM_DEFAULT_SESSION): boolean => {
189
+ const socket = this.getSocket(sessionId);
190
+
191
+ return socket ? socket.connected : false;
192
+ };
100
193
 
101
194
  /**
102
195
  * Tells if LLM socket is binding
196
+ * @param {string} sessionId - Connection identifier
103
197
  * @returns {string} binding
104
198
  */
105
- public getBinding = (): string => this.binding;
199
+ public getBinding = (sessionId = LLM_DEFAULT_SESSION): string => {
200
+ const sessionData = this.connections.get(sessionId);
201
+
202
+ return sessionData?.binding;
203
+ };
106
204
 
107
205
  /**
108
206
  * Get Locus URL for the connection
207
+ * @param {string} sessionId - Connection identifier
109
208
  * @returns {string} locus Url
110
209
  */
111
- public getLocusUrl = (): string => this.locusUrl;
210
+ public getLocusUrl = (sessionId = LLM_DEFAULT_SESSION): string => {
211
+ const sessionData = this.connections.get(sessionId);
212
+
213
+ return sessionData?.locusUrl;
214
+ };
112
215
 
113
216
  /**
114
217
  * Get data channel URL for the connection
218
+ * @param {string} sessionId - Connection identifier
115
219
  * @returns {string} data channel Url
116
220
  */
117
- public getDatachannelUrl = (): string => this.datachannelUrl;
221
+ public getDatachannelUrl = (sessionId = LLM_DEFAULT_SESSION): string => {
222
+ const sessionData = this.connections.get(sessionId);
223
+
224
+ return sessionData?.datachannelUrl;
225
+ };
226
+
227
+ /**
228
+ * Get WebSocket URL for the connection
229
+ * @param {string} sessionId - Connection identifier
230
+ * @returns {string | undefined} WebSocket URL
231
+ */
232
+ public getWebSocketUrl = (sessionId = LLM_DEFAULT_SESSION): string | undefined => {
233
+ const sessionData = this.connections.get(sessionId);
234
+
235
+ return sessionData?.webSocketUrl;
236
+ };
237
+
238
+ /**
239
+ * Set the owner meeting ID for a given LLM session. Used by the meetings
240
+ * plugin to tag which Meeting instance currently owns the (default) LLM
241
+ * connection so that other Meeting instances can avoid disconnecting or
242
+ * re-initializing a connection they do not own.
243
+ *
244
+ * Does NOT create a connections entry if one does not already exist — this
245
+ * method is a no-op when there is no active session data. Callers should
246
+ * invoke it after a successful `registerAndConnect` or during an explicit
247
+ * ownership handoff.
248
+ *
249
+ * @param {string | undefined} ownerMeetingId - Meeting ID (or undefined to clear)
250
+ * @param {string} sessionId - Connection identifier (defaults to default session)
251
+ * @returns {void}
252
+ */
253
+ public setOwnerMeetingId = (
254
+ ownerMeetingId: string | undefined,
255
+ sessionId: string = LLM_DEFAULT_SESSION
256
+ ): void => {
257
+ const sessionData = this.connections.get(sessionId);
258
+
259
+ if (!sessionData) {
260
+ return;
261
+ }
262
+
263
+ sessionData.ownerMeetingId = ownerMeetingId;
264
+ this.connections.set(sessionId, sessionData);
265
+ };
266
+
267
+ /**
268
+ * Get the owner meeting ID currently associated with an LLM session.
269
+ * Returns undefined when no owner has been assigned (e.g. before the
270
+ * first successful `registerAndConnect`, or after `disconnectLLM`).
271
+ *
272
+ * @param {string} sessionId - Connection identifier (defaults to default session)
273
+ * @returns {string | undefined} ownerMeetingId
274
+ */
275
+ public getOwnerMeetingId = (sessionId: string = LLM_DEFAULT_SESSION): string | undefined => {
276
+ const sessionData = this.connections.get(sessionId);
277
+
278
+ return sessionData?.ownerMeetingId;
279
+ };
280
+
281
+ /**
282
+ * Resolve ownership information for an LLM session.
283
+ *
284
+ * Rules:
285
+ * - no current owner => caller may proceed
286
+ * - caller has no identity to assert => treat as owner
287
+ * - otherwise caller must match current owner
288
+ *
289
+ * @param {string | undefined} ownerMeetingId - Candidate owner to evaluate
290
+ * @param {string} sessionId - Connection identifier (defaults to default session)
291
+ * @returns {{currentOwner: (string|undefined), isOwner: boolean}}
292
+ */
293
+ public resolveSessionOwnership = (
294
+ ownerMeetingId?: string,
295
+ sessionId: string = LLM_DEFAULT_SESSION
296
+ ): {
297
+ currentOwner: string | undefined;
298
+ isOwner: boolean;
299
+ } => {
300
+ const currentOwner = this.getOwnerMeetingId(sessionId);
301
+ const isOwner = !currentOwner || !ownerMeetingId || currentOwner === ownerMeetingId;
302
+
303
+ return {
304
+ currentOwner,
305
+ isOwner,
306
+ };
307
+ };
308
+
309
+ /**
310
+ * Get data channel token for the connection
311
+ * @param {DataChannelTokenType|string} tokenKey
312
+ * @param {string | undefined} ownerMeetingId - Meeting id asserting read ownership
313
+ * @returns {string | undefined} data channel token
314
+ */
315
+ public getDatachannelToken = (
316
+ tokenKey?: DataChannelTokenType | string,
317
+ ownerMeetingId?: string
318
+ ): string | undefined => {
319
+ const resolvedTokenKey = tokenKey ?? DataChannelTokenType.Default;
320
+
321
+ const {currentOwner, isOwner} = this.resolveSessionOwnership(ownerMeetingId, resolvedTokenKey);
322
+
323
+ if (!isOwner) {
324
+ this.logger.info(
325
+ `llm#getDatachannelToken --> skip read for session ${resolvedTokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
326
+ );
327
+
328
+ return undefined;
329
+ }
330
+
331
+ return this.datachannelTokens[resolvedTokenKey];
332
+ };
333
+
334
+ /**
335
+ * Set data channel token for the connection
336
+ * @param {string} datachannelToken - data channel token
337
+ * @param {DataChannelTokenType|string} [tokenKey]
338
+ * @param {string | undefined} ownerMeetingId - Meeting id asserting write ownership
339
+ * @returns {void}
340
+ */
341
+ public setDatachannelToken = (
342
+ datachannelToken: string,
343
+ tokenKey?: DataChannelTokenType | string,
344
+ ownerMeetingId?: string
345
+ ): void => {
346
+ const resolvedTokenKey = tokenKey ?? DataChannelTokenType.Default;
347
+
348
+ const {currentOwner, isOwner} = this.resolveSessionOwnership(ownerMeetingId, resolvedTokenKey);
349
+
350
+ if (!isOwner) {
351
+ this.logger.info(
352
+ `llm#setDatachannelToken --> skip write for session ${resolvedTokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
353
+ );
354
+
355
+ return;
356
+ }
357
+
358
+ this.datachannelTokens[resolvedTokenKey] = datachannelToken;
359
+ };
360
+
361
+ /**
362
+ * Clears a single session's data channel token.
363
+ * @param {DataChannelTokenType|string} tokenKey
364
+ * @param {string} ownerMeetingId - Meeting id asserting delete ownership
365
+ * @returns {void}
366
+ */
367
+ public clearDatachannelToken = (
368
+ tokenKey: DataChannelTokenType | string,
369
+ ownerMeetingId: string
370
+ ): void => {
371
+ const resolvedTokenKey = tokenKey;
372
+
373
+ const {currentOwner, isOwner} = this.resolveSessionOwnership(ownerMeetingId, resolvedTokenKey);
374
+
375
+ if (!isOwner) {
376
+ this.logger.info(
377
+ `llm#clearDatachannelToken --> skip clear for session ${resolvedTokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
378
+ );
379
+
380
+ return;
381
+ }
382
+
383
+ this.datachannelTokens[resolvedTokenKey] = undefined;
384
+ delete this.datachannelTokens[resolvedTokenKey];
385
+ };
386
+
387
+ /**
388
+ * Set the handler used to refresh the DataChannel token
389
+ *
390
+ * @param {function} handler - Function that returns a refreshed token
391
+ * @param {string} [sessionId] - Connection identifier
392
+ * @param {string | undefined} ownerMeetingId - Meeting id asserting refresh-handler ownership
393
+ * @returns {void}
394
+ */
395
+ public setRefreshHandler(
396
+ handler: () => Promise<{
397
+ body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType};
398
+ }>,
399
+ sessionId?: string,
400
+ ownerMeetingId?: string
401
+ ) {
402
+ const resolvedSessionId = sessionId ?? LLM_DEFAULT_SESSION;
403
+
404
+ const {currentOwner, isOwner} = this.resolveSessionOwnership(ownerMeetingId, resolvedSessionId);
405
+
406
+ if (!isOwner) {
407
+ this.logger.info(
408
+ `llm#setRefreshHandler --> skip write for session ${resolvedSessionId}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
409
+ );
410
+
411
+ return;
412
+ }
413
+
414
+ const sessionData = this.connections.get(resolvedSessionId);
415
+
416
+ if (sessionData) {
417
+ sessionData.refreshHandler = handler;
418
+ if (ownerMeetingId) {
419
+ sessionData.ownerMeetingId = ownerMeetingId;
420
+ }
421
+
422
+ return;
423
+ }
424
+
425
+ // Intentionally allow a pre-connection session shape here.
426
+ // Some flows inject refreshHandler before register/connect so token refresh
427
+ // is already wired when the socket lifecycle starts. register()/
428
+ // registerAndConnect() will later fill webSocketUrl/binding/locusUrl/
429
+ // datachannelUrl into this same session entry.
430
+ this.connections.set(resolvedSessionId, {
431
+ refreshHandler: handler,
432
+ ownerMeetingId,
433
+ });
434
+ }
435
+
436
+ /**
437
+ * Refresh the data channel token using the injected handler.
438
+ * Logs a descriptive error if the handler is missing or fails.
439
+ * @param {string} sessionId - Connection identifier (defaults to default session)
440
+ * @returns {Promise<string>} The refreshed token.
441
+ */
442
+ public async refreshDataChannelToken(sessionId: string = LLM_DEFAULT_SESSION) {
443
+ const refreshHandler = this.connections.get(sessionId)?.refreshHandler;
444
+
445
+ if (!refreshHandler) {
446
+ this.logger.warn(
447
+ `llm#refreshDataChannelToken --> LLM refreshHandler is not set for session ${sessionId}, skipping token refresh`
448
+ );
449
+
450
+ return null;
451
+ }
452
+
453
+ try {
454
+ const res = await refreshHandler();
455
+
456
+ return res;
457
+ } catch (error: any) {
458
+ this.logger.warn(
459
+ `llm#refreshDataChannelToken --> DataChannel token refresh failed (likely locus changed or participant left): ${
460
+ error?.message || error
461
+ }`
462
+ );
463
+
464
+ return null;
465
+ }
466
+ }
118
467
 
119
468
  /**
120
469
  * Disconnects websocket connection
121
470
  * @param {{code: number, reason: string}} options - The disconnect option object with code and reason
471
+ * @param {string} sessionId - Connection identifier
472
+ * @param {string} ownerMeetingId - Meeting id asserting disconnect ownership
473
+ * @returns {Promise<boolean>} True when disconnect was performed, false when skipped
474
+ */
475
+ public disconnectLLM = (
476
+ options: {code: number; reason: string},
477
+ sessionId?: string,
478
+ ownerMeetingId?: string
479
+ ): Promise<boolean> => {
480
+ const resolvedSessionId = sessionId ?? LLM_DEFAULT_SESSION;
481
+
482
+ // Backward-compat path: historically callers could omit ownerMeetingId
483
+ // (and sometimes sessionId). Reuse current owner when available so legacy
484
+ // calls remain best-effort without throwing at teardown time.
485
+ const resolvedOwnerMeetingId = ownerMeetingId || this.getOwnerMeetingId(resolvedSessionId);
486
+
487
+ if (!ownerMeetingId) {
488
+ this.logger.warn(
489
+ `llm#disconnectLLM --> ownerMeetingId is omitted for session ${resolvedSessionId}; using legacy compatibility path`
490
+ );
491
+ }
492
+
493
+ const {currentOwner, isOwner} = this.resolveSessionOwnership(
494
+ resolvedOwnerMeetingId,
495
+ resolvedSessionId
496
+ );
497
+
498
+ if (!isOwner) {
499
+ this.logger.info(
500
+ `llm#disconnectLLM --> skip disconnect for session ${resolvedSessionId}; owned by ${currentOwner}, candidate ${resolvedOwnerMeetingId}`
501
+ );
502
+
503
+ return Promise.resolve(false);
504
+ }
505
+
506
+ return this.disconnect(options, resolvedSessionId).then(() => {
507
+ // Clear owner tag before cleanup to ensure it's not lingering
508
+ // if another meeting claimed it during disconnect
509
+ this.setOwnerMeetingId(undefined, resolvedSessionId);
510
+
511
+ // Clean up sessions data
512
+ this.connections.delete(resolvedSessionId);
513
+
514
+ return true;
515
+ });
516
+ };
517
+
518
+ /**
519
+ * Disconnects all LLM websocket connections
520
+ * @param {{code: number, reason: string}} options - The disconnect option object with code and reason
122
521
  * @returns {Promise<void>}
123
522
  */
124
- public disconnectLLM = (options: object): Promise<void> =>
125
- this.disconnect(options).then(() => {
126
- this.locusUrl = undefined;
127
- this.datachannelUrl = undefined;
128
- this.binding = undefined;
129
- this.webSocketUrl = undefined;
523
+ public disconnectAllLLM = (options?: {code: number; reason: string}): Promise<void> =>
524
+ this.disconnectAll(options).then(() => {
525
+ // Clean up all connection data
526
+ this.connections.clear();
130
527
  });
528
+
529
+ /**
530
+ * Get all active LLM connections
531
+ * @returns {Map} Map of sessionId to session data
532
+ */
533
+ public getAllConnections = (): Map<
534
+ string,
535
+ {
536
+ webSocketUrl?: string;
537
+ binding?: string;
538
+ locusUrl?: string;
539
+ datachannelUrl?: string;
540
+ ownerMeetingId?: string;
541
+ }
542
+ > => new Map(this.connections);
543
+
544
+ /**
545
+ * Look up the locusUrl associated with a datachannel request URL.
546
+ * Iterates all active LLM sessions and returns the locusUrl of the
547
+ * session whose stored datachannelUrl is a prefix of the given request URL.
548
+ *
549
+ * @param {string} requestUrl - The in-flight request URL to match
550
+ * @returns {string | undefined} The matching locusUrl, or undefined if not found
551
+ */
552
+ public getLocusUrlByDatachannelUrl(requestUrl: string): string | undefined {
553
+ for (const [, connection] of this.connections) {
554
+ if (
555
+ connection.datachannelUrl &&
556
+ LLMChannel.matchesDatachannelRequestUrl(requestUrl, connection.datachannelUrl)
557
+ ) {
558
+ return connection.locusUrl;
559
+ }
560
+ }
561
+
562
+ return undefined;
563
+ }
564
+
565
+ /**
566
+ * Look up the sessionId associated with a datachannel request URL.
567
+ * Iterates all active LLM sessions and returns the sessionId whose
568
+ * stored datachannelUrl is a prefix of the given request URL.
569
+ *
570
+ * @param {string} requestUrl - The in-flight request URL to match
571
+ * @returns {string | undefined} The matching sessionId, or undefined if not found
572
+ */
573
+ public getSessionIdByDatachannelUrl(requestUrl: string): string | undefined {
574
+ for (const [sessionId, connection] of this.connections) {
575
+ if (
576
+ connection.datachannelUrl &&
577
+ LLMChannel.matchesDatachannelRequestUrl(requestUrl, connection.datachannelUrl)
578
+ ) {
579
+ return sessionId;
580
+ }
581
+ }
582
+
583
+ return undefined;
584
+ }
585
+
586
+ /**
587
+ * Matches a request URL to a stored datachannel registration URL.
588
+ * Host can differ (e.g. rewritten by hostmap interceptor), so we first
589
+ * try full URL prefix and then fall back to pathname prefix.
590
+ * @param {string} requestUrl
591
+ * @param {string} registrationUrl
592
+ * @returns {boolean}
593
+ */
594
+ public static matchesDatachannelRequestUrl(requestUrl: string, registrationUrl: string): boolean {
595
+ if (!requestUrl || !registrationUrl) {
596
+ return false;
597
+ }
598
+
599
+ if (requestUrl.startsWith(registrationUrl)) {
600
+ return true;
601
+ }
602
+
603
+ try {
604
+ const request = new URL(requestUrl);
605
+ const registration = new URL(registrationUrl);
606
+
607
+ return request.pathname.startsWith(registration.pathname);
608
+ } catch (error) {
609
+ return false;
610
+ }
611
+ }
612
+
613
+ /**
614
+ * Returns true if data channel token is enabled, false otherwise
615
+ * @returns {Promise<boolean>} resolves with true if data channel token is enabled
616
+ */
617
+ public isDataChannelTokenEnabled(): Promise<boolean> {
618
+ // @ts-ignore
619
+ return this.webex.internal.feature.getFeature('developer', DATA_CHANNEL_WITH_JWT_TOKEN);
620
+ }
621
+
622
+ /**
623
+ * Builds a WebSocket URL with the `subscriptionAwareSubchannels` query parameter.
624
+ *
625
+ * @param {string} baseUrl - The original WebSocket URL.
626
+ * @param {string[]} subchannels - List of subchannels to declare as subscription-aware.
627
+ * @returns {string} The final URL with updated query parameters.
628
+ */
629
+
630
+ public static buildUrlWithAwareSubchannels = (baseUrl: string, subchannels: string[]) => {
631
+ const urlObj = new URL(baseUrl);
632
+ urlObj.searchParams.set(SUBSCRIPTION_AWARE_SUBCHANNELS_PARAM, subchannels.join(','));
633
+
634
+ return urlObj.toString();
635
+ };
131
636
  }