@webex/internal-plugin-llm 3.12.0-next.2 → 3.12.0-next.20

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
@@ -51,6 +51,7 @@ export default class LLMChannel extends (Mercury as any) implements ILLMChannel
51
51
  defaultSessionId = LLM_DEFAULT_SESSION;
52
52
  /**
53
53
  * Map to store connection-specific data for multiple LLM connections
54
+ * Key: sessionId
54
55
  * @private
55
56
  * @type {Map<string, {webSocketUrl?: string; binding?: string; locusUrl?: string; datachannelUrl?: string}>}
56
57
  */
@@ -61,19 +62,20 @@ export default class LLMChannel extends (Mercury as any) implements ILLMChannel
61
62
  binding?: string;
62
63
  locusUrl?: string;
63
64
  datachannelUrl?: string;
64
- datachannelToken?: string;
65
+ ownerMeetingId?: string;
66
+ refreshHandler?: () => Promise<{
67
+ body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType};
68
+ }>;
65
69
  }
66
70
  > = new Map();
67
71
 
68
- private datachannelTokens: Record<DataChannelTokenType, 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> = {
69
75
  [DataChannelTokenType.Default]: undefined,
70
76
  [DataChannelTokenType.PracticeSession]: undefined,
71
77
  };
72
78
 
73
- private refreshHandler?: () => Promise<{
74
- body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType};
75
- }>;
76
-
77
79
  /**
78
80
  * Register to the websocket
79
81
  * @param {string} llmSocketUrl
@@ -123,16 +125,24 @@ export default class LLMChannel extends (Mercury as any) implements ILLMChannel
123
125
  datachannelUrl: string,
124
126
  datachannelToken?: string,
125
127
  sessionId: string = LLM_DEFAULT_SESSION
126
- ): Promise<void> =>
127
- this.register(datachannelUrl, datachannelToken, sessionId).then(async () => {
128
- if (!locusUrl || !datachannelUrl) return undefined;
129
-
130
- // Get or create connection data
128
+ ): Promise<void> => {
129
+ // Pre-populate locusUrl and datachannelUrl before register() fires the
130
+ // HTTP POST, so that any token refresh triggered during registration can
131
+ // be routed via connections without falling back to a locusInfo URL scan.
132
+ if (locusUrl && datachannelUrl) {
131
133
  const sessionData = this.connections.get(sessionId) || {};
132
134
  sessionData.locusUrl = locusUrl;
133
135
  sessionData.datachannelUrl = datachannelUrl;
134
- sessionData.datachannelToken = datachannelToken;
135
136
  this.connections.set(sessionId, sessionData);
137
+ }
138
+
139
+ return this.register(datachannelUrl, datachannelToken, sessionId).then(async () => {
140
+ if (!locusUrl || !datachannelUrl) return undefined;
141
+
142
+ // locusUrl and datachannelUrl were pre-populated before register(); here
143
+ // we only need to read the existing session data to get webSocketUrl/binding
144
+ // that register() filled in.
145
+ const sessionData = this.connections.get(sessionId) || {};
136
146
 
137
147
  const isDataChannelTokenEnabled = await this.isDataChannelTokenEnabled();
138
148
 
@@ -142,6 +152,7 @@ export default class LLMChannel extends (Mercury as any) implements ILLMChannel
142
152
 
143
153
  return this.connect(connectUrl, sessionId);
144
154
  });
155
+ };
145
156
 
146
157
  /**
147
158
  * Tells if LLM socket is connected
@@ -187,73 +198,223 @@ export default class LLMChannel extends (Mercury as any) implements ILLMChannel
187
198
  return sessionData?.datachannelUrl;
188
199
  };
189
200
 
201
+ /**
202
+ * Set the owner meeting ID for a given LLM session. Used by the meetings
203
+ * plugin to tag which Meeting instance currently owns the (default) LLM
204
+ * connection so that other Meeting instances can avoid disconnecting or
205
+ * re-initializing a connection they do not own.
206
+ *
207
+ * Does NOT create a connections entry if one does not already exist — this
208
+ * method is a no-op when there is no active session data. Callers should
209
+ * invoke it after a successful `registerAndConnect` or during an explicit
210
+ * ownership handoff.
211
+ *
212
+ * @param {string | undefined} ownerMeetingId - Meeting ID (or undefined to clear)
213
+ * @param {string} sessionId - Connection identifier (defaults to default session)
214
+ * @returns {void}
215
+ */
216
+ public setOwnerMeetingId = (
217
+ ownerMeetingId: string | undefined,
218
+ sessionId: string = LLM_DEFAULT_SESSION
219
+ ): void => {
220
+ const sessionData = this.connections.get(sessionId);
221
+
222
+ if (!sessionData) {
223
+ return;
224
+ }
225
+
226
+ sessionData.ownerMeetingId = ownerMeetingId;
227
+ this.connections.set(sessionId, sessionData);
228
+ };
229
+
230
+ /**
231
+ * Get the owner meeting ID currently associated with an LLM session.
232
+ * Returns undefined when no owner has been assigned (e.g. before the
233
+ * first successful `registerAndConnect`, or after `disconnectLLM`).
234
+ *
235
+ * @param {string} sessionId - Connection identifier (defaults to default session)
236
+ * @returns {string | undefined} ownerMeetingId
237
+ */
238
+ public getOwnerMeetingId = (sessionId: string = LLM_DEFAULT_SESSION): string | undefined => {
239
+ const sessionData = this.connections.get(sessionId);
240
+
241
+ return sessionData?.ownerMeetingId;
242
+ };
243
+
244
+ /**
245
+ * Resolve ownership information for an LLM session.
246
+ *
247
+ * Rules:
248
+ * - no current owner => caller may proceed
249
+ * - caller has no identity to assert => treat as owner
250
+ * - otherwise caller must match current owner
251
+ *
252
+ * @param {string | undefined} ownerMeetingId - Candidate owner to evaluate
253
+ * @param {string} sessionId - Connection identifier (defaults to default session)
254
+ * @returns {{currentOwner: (string|undefined), isOwner: boolean}}
255
+ */
256
+ public resolveSessionOwnership = (
257
+ ownerMeetingId?: string,
258
+ sessionId: string = LLM_DEFAULT_SESSION
259
+ ): {
260
+ currentOwner: string | undefined;
261
+ isOwner: boolean;
262
+ } => {
263
+ const currentOwner = this.getOwnerMeetingId(sessionId);
264
+ const isOwner = !currentOwner || !ownerMeetingId || currentOwner === ownerMeetingId;
265
+
266
+ return {
267
+ currentOwner,
268
+ isOwner,
269
+ };
270
+ };
271
+
190
272
  /**
191
273
  * Get data channel token for the connection
192
- * @param {DataChannelTokenType} dataChannelTokenType
193
- * @returns {string} data channel token
274
+ * @param {DataChannelTokenType|string} tokenKey
275
+ * @param {string | undefined} ownerMeetingId - Meeting id asserting read ownership
276
+ * @returns {string | undefined} data channel token
194
277
  */
195
278
  public getDatachannelToken = (
196
- dataChannelTokenType: DataChannelTokenType = DataChannelTokenType.Default
197
- ): string => {
198
- return this.datachannelTokens[dataChannelTokenType];
279
+ tokenKey?: DataChannelTokenType | string,
280
+ ownerMeetingId?: string
281
+ ): string | undefined => {
282
+ const resolvedTokenKey = tokenKey ?? DataChannelTokenType.Default;
283
+
284
+ const {currentOwner, isOwner} = this.resolveSessionOwnership(ownerMeetingId, resolvedTokenKey);
285
+
286
+ if (!isOwner) {
287
+ this.logger.info(
288
+ `llm#getDatachannelToken --> skip read for session ${resolvedTokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
289
+ );
290
+
291
+ return undefined;
292
+ }
293
+
294
+ return this.datachannelTokens[resolvedTokenKey];
199
295
  };
200
296
 
201
297
  /**
202
298
  * Set data channel token for the connection
203
299
  * @param {string} datachannelToken - data channel token
204
- * @param {DataChannelTokenType} dataChannelTokenType
300
+ * @param {DataChannelTokenType|string} [tokenKey]
301
+ * @param {string | undefined} ownerMeetingId - Meeting id asserting write ownership
205
302
  * @returns {void}
206
303
  */
207
304
  public setDatachannelToken = (
208
305
  datachannelToken: string,
209
- dataChannelTokenType: DataChannelTokenType = DataChannelTokenType.Default
306
+ tokenKey?: DataChannelTokenType | string,
307
+ ownerMeetingId?: string
210
308
  ): void => {
211
- this.datachannelTokens[dataChannelTokenType] = datachannelToken;
309
+ const resolvedTokenKey = tokenKey ?? DataChannelTokenType.Default;
310
+
311
+ const {currentOwner, isOwner} = this.resolveSessionOwnership(ownerMeetingId, resolvedTokenKey);
312
+
313
+ if (!isOwner) {
314
+ this.logger.info(
315
+ `llm#setDatachannelToken --> skip write for session ${resolvedTokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
316
+ );
317
+
318
+ return;
319
+ }
320
+
321
+ this.datachannelTokens[resolvedTokenKey] = datachannelToken;
212
322
  };
213
323
 
214
324
  /**
215
- * Resets all data‑channel tokens to their initial undefined values.
216
- * Used when leaving or disconnecting from a meeting.
325
+ * Clears a single session's data channel token.
326
+ * @param {DataChannelTokenType|string} tokenKey
327
+ * @param {string} ownerMeetingId - Meeting id asserting delete ownership
217
328
  * @returns {void}
218
329
  */
219
- public resetDatachannelTokens() {
220
- this.datachannelTokens = {
221
- [DataChannelTokenType.Default]: undefined,
222
- [DataChannelTokenType.PracticeSession]: undefined,
223
- };
224
- }
330
+ public clearDatachannelToken = (
331
+ tokenKey: DataChannelTokenType | string,
332
+ ownerMeetingId: string
333
+ ): void => {
334
+ const resolvedTokenKey = tokenKey;
335
+
336
+ const {currentOwner, isOwner} = this.resolveSessionOwnership(ownerMeetingId, resolvedTokenKey);
337
+
338
+ if (!isOwner) {
339
+ this.logger.info(
340
+ `llm#clearDatachannelToken --> skip clear for session ${resolvedTokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
341
+ );
342
+
343
+ return;
344
+ }
345
+
346
+ this.datachannelTokens[resolvedTokenKey] = undefined;
347
+ delete this.datachannelTokens[resolvedTokenKey];
348
+ };
225
349
 
226
350
  /**
227
351
  * Set the handler used to refresh the DataChannel token
228
352
  *
229
353
  * @param {function} handler - Function that returns a refreshed token
354
+ * @param {string} [sessionId] - Connection identifier
355
+ * @param {string | undefined} ownerMeetingId - Meeting id asserting refresh-handler ownership
230
356
  * @returns {void}
231
357
  */
232
358
  public setRefreshHandler(
233
359
  handler: () => Promise<{
234
360
  body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType};
235
- }>
361
+ }>,
362
+ sessionId?: string,
363
+ ownerMeetingId?: string
236
364
  ) {
237
- this.refreshHandler = handler;
365
+ const resolvedSessionId = sessionId ?? LLM_DEFAULT_SESSION;
366
+
367
+ const {currentOwner, isOwner} = this.resolveSessionOwnership(ownerMeetingId, resolvedSessionId);
368
+
369
+ if (!isOwner) {
370
+ this.logger.info(
371
+ `llm#setRefreshHandler --> skip write for session ${resolvedSessionId}; owned by ${currentOwner}, candidate ${ownerMeetingId}`
372
+ );
373
+
374
+ return;
375
+ }
376
+
377
+ const sessionData = this.connections.get(resolvedSessionId);
378
+
379
+ if (sessionData) {
380
+ sessionData.refreshHandler = handler;
381
+ if (ownerMeetingId) {
382
+ sessionData.ownerMeetingId = ownerMeetingId;
383
+ }
384
+
385
+ return;
386
+ }
387
+
388
+ // Intentionally allow a pre-connection session shape here.
389
+ // Some flows inject refreshHandler before register/connect so token refresh
390
+ // is already wired when the socket lifecycle starts. register()/
391
+ // registerAndConnect() will later fill webSocketUrl/binding/locusUrl/
392
+ // datachannelUrl into this same session entry.
393
+ this.connections.set(resolvedSessionId, {
394
+ refreshHandler: handler,
395
+ ownerMeetingId,
396
+ });
238
397
  }
239
398
 
240
399
  /**
241
400
  * Refresh the data channel token using the injected handler.
242
401
  * Logs a descriptive error if the handler is missing or fails.
243
- *
402
+ * @param {string} sessionId - Connection identifier (defaults to default session)
244
403
  * @returns {Promise<string>} The refreshed token.
245
404
  */
246
- public async refreshDataChannelToken() {
247
- if (!this.refreshHandler) {
405
+ public async refreshDataChannelToken(sessionId: string = LLM_DEFAULT_SESSION) {
406
+ const refreshHandler = this.connections.get(sessionId)?.refreshHandler;
407
+
408
+ if (!refreshHandler) {
248
409
  this.logger.warn(
249
- 'llm#refreshDataChannelToken --> LLM refreshHandler is not set, skipping token refresh'
410
+ `llm#refreshDataChannelToken --> LLM refreshHandler is not set for session ${sessionId}, skipping token refresh`
250
411
  );
251
412
 
252
413
  return null;
253
414
  }
254
415
 
255
416
  try {
256
- const res = await this.refreshHandler();
417
+ const res = await refreshHandler();
257
418
 
258
419
  return res;
259
420
  } catch (error: any) {
@@ -271,16 +432,51 @@ export default class LLMChannel extends (Mercury as any) implements ILLMChannel
271
432
  * Disconnects websocket connection
272
433
  * @param {{code: number, reason: string}} options - The disconnect option object with code and reason
273
434
  * @param {string} sessionId - Connection identifier
274
- * @returns {Promise<void>}
435
+ * @param {string} ownerMeetingId - Meeting id asserting disconnect ownership
436
+ * @returns {Promise<boolean>} True when disconnect was performed, false when skipped
275
437
  */
276
438
  public disconnectLLM = (
277
439
  options: {code: number; reason: string},
278
- sessionId: string = LLM_DEFAULT_SESSION
279
- ): Promise<void> =>
280
- this.disconnect(options, sessionId).then(() => {
440
+ sessionId?: string,
441
+ ownerMeetingId?: string
442
+ ): Promise<boolean> => {
443
+ const resolvedSessionId = sessionId ?? LLM_DEFAULT_SESSION;
444
+
445
+ // Backward-compat path: historically callers could omit ownerMeetingId
446
+ // (and sometimes sessionId). Reuse current owner when available so legacy
447
+ // calls remain best-effort without throwing at teardown time.
448
+ const resolvedOwnerMeetingId = ownerMeetingId || this.getOwnerMeetingId(resolvedSessionId);
449
+
450
+ if (!ownerMeetingId) {
451
+ this.logger.warn(
452
+ `llm#disconnectLLM --> ownerMeetingId is omitted for session ${resolvedSessionId}; using legacy compatibility path`
453
+ );
454
+ }
455
+
456
+ const {currentOwner, isOwner} = this.resolveSessionOwnership(
457
+ resolvedOwnerMeetingId,
458
+ resolvedSessionId
459
+ );
460
+
461
+ if (!isOwner) {
462
+ this.logger.info(
463
+ `llm#disconnectLLM --> skip disconnect for session ${resolvedSessionId}; owned by ${currentOwner}, candidate ${resolvedOwnerMeetingId}`
464
+ );
465
+
466
+ return Promise.resolve(false);
467
+ }
468
+
469
+ return this.disconnect(options, resolvedSessionId).then(() => {
470
+ // Clear owner tag before cleanup to ensure it's not lingering
471
+ // if another meeting claimed it during disconnect
472
+ this.setOwnerMeetingId(undefined, resolvedSessionId);
473
+
281
474
  // Clean up sessions data
282
- this.connections.delete(sessionId);
475
+ this.connections.delete(resolvedSessionId);
476
+
477
+ return true;
283
478
  });
479
+ };
284
480
 
285
481
  /**
286
482
  * Disconnects all LLM websocket connections
@@ -304,10 +500,79 @@ export default class LLMChannel extends (Mercury as any) implements ILLMChannel
304
500
  binding?: string;
305
501
  locusUrl?: string;
306
502
  datachannelUrl?: string;
307
- datachannelToken?: string;
503
+ ownerMeetingId?: string;
308
504
  }
309
505
  > => new Map(this.connections);
310
506
 
507
+ /**
508
+ * Look up the locusUrl associated with a datachannel request URL.
509
+ * Iterates all active LLM sessions and returns the locusUrl of the
510
+ * session whose stored datachannelUrl is a prefix of the given request URL.
511
+ *
512
+ * @param {string} requestUrl - The in-flight request URL to match
513
+ * @returns {string | undefined} The matching locusUrl, or undefined if not found
514
+ */
515
+ public getLocusUrlByDatachannelUrl(requestUrl: string): string | undefined {
516
+ for (const [, connection] of this.connections) {
517
+ if (
518
+ connection.datachannelUrl &&
519
+ LLMChannel.matchesDatachannelRequestUrl(requestUrl, connection.datachannelUrl)
520
+ ) {
521
+ return connection.locusUrl;
522
+ }
523
+ }
524
+
525
+ return undefined;
526
+ }
527
+
528
+ /**
529
+ * Look up the sessionId associated with a datachannel request URL.
530
+ * Iterates all active LLM sessions and returns the sessionId whose
531
+ * stored datachannelUrl is a prefix of the given request URL.
532
+ *
533
+ * @param {string} requestUrl - The in-flight request URL to match
534
+ * @returns {string | undefined} The matching sessionId, or undefined if not found
535
+ */
536
+ public getSessionIdByDatachannelUrl(requestUrl: string): string | undefined {
537
+ for (const [sessionId, connection] of this.connections) {
538
+ if (
539
+ connection.datachannelUrl &&
540
+ LLMChannel.matchesDatachannelRequestUrl(requestUrl, connection.datachannelUrl)
541
+ ) {
542
+ return sessionId;
543
+ }
544
+ }
545
+
546
+ return undefined;
547
+ }
548
+
549
+ /**
550
+ * Matches a request URL to a stored datachannel registration URL.
551
+ * Host can differ (e.g. rewritten by hostmap interceptor), so we first
552
+ * try full URL prefix and then fall back to pathname prefix.
553
+ * @param {string} requestUrl
554
+ * @param {string} registrationUrl
555
+ * @returns {boolean}
556
+ */
557
+ public static matchesDatachannelRequestUrl(requestUrl: string, registrationUrl: string): boolean {
558
+ if (!requestUrl || !registrationUrl) {
559
+ return false;
560
+ }
561
+
562
+ if (requestUrl.startsWith(registrationUrl)) {
563
+ return true;
564
+ }
565
+
566
+ try {
567
+ const request = new URL(requestUrl);
568
+ const registration = new URL(registrationUrl);
569
+
570
+ return request.pathname.startsWith(registration.pathname);
571
+ } catch (error) {
572
+ return false;
573
+ }
574
+ }
575
+
311
576
  /**
312
577
  * Returns true if data channel token is enabled, false otherwise
313
578
  * @returns {Promise<boolean>} resolves with true if data channel token is enabled
package/src/llm.types.ts CHANGED
@@ -1,3 +1,10 @@
1
+ export enum DataChannelTokenType {
2
+ Default = 'llm-default-session',
3
+ PracticeSession = 'llm-practice-session',
4
+ }
5
+
6
+ type DataChannelTokenKey = DataChannelTokenType | string;
7
+
1
8
  interface ILLMChannel {
2
9
  registerAndConnect: (
3
10
  locusUrl: string,
@@ -9,8 +16,43 @@ interface ILLMChannel {
9
16
  getBinding: (sessionId?: string) => string;
10
17
  getLocusUrl: (sessionId?: string) => string;
11
18
  getDatachannelUrl: (sessionId?: string) => string;
12
- disconnectLLM: (options: {code: number; reason: string}, sessionId?: string) => Promise<void>;
19
+ disconnectLLM: (
20
+ options: {code: number; reason: string},
21
+ sessionId?: string,
22
+ ownerMeetingId?: string
23
+ ) => Promise<boolean>;
13
24
  disconnectAllLLM: (options?: {code: number; reason: string}) => Promise<void>;
25
+ setOwnerMeetingId: (ownerMeetingId: string | undefined, sessionId?: string) => void;
26
+ getOwnerMeetingId: (sessionId?: string) => string | undefined;
27
+ resolveSessionOwnership: (
28
+ ownerMeetingId?: string,
29
+ sessionId?: string
30
+ ) => {
31
+ currentOwner: string | undefined;
32
+ isOwner: boolean;
33
+ };
34
+ getDatachannelToken: (
35
+ tokenKey?: DataChannelTokenKey,
36
+ ownerMeetingId?: string
37
+ ) => string | undefined;
38
+ setDatachannelToken: (
39
+ datachannelToken: string,
40
+ tokenKey?: DataChannelTokenKey,
41
+ ownerMeetingId?: string
42
+ ) => void;
43
+ clearDatachannelToken: (tokenKey: DataChannelTokenKey, ownerMeetingId: string) => void;
44
+ setRefreshHandler: (
45
+ handler: () => Promise<{
46
+ body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType};
47
+ }>,
48
+ sessionId?: string,
49
+ ownerMeetingId?: string
50
+ ) => void;
51
+ refreshDataChannelToken: (sessionId?: string) => Promise<{
52
+ body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType};
53
+ } | null>;
54
+ getLocusUrlByDatachannelUrl: (requestUrl: string) => string | undefined;
55
+ getSessionIdByDatachannelUrl: (requestUrl: string) => string | undefined;
14
56
  getAllConnections: () => Map<
15
57
  string,
16
58
  {
@@ -18,15 +60,10 @@ interface ILLMChannel {
18
60
  binding?: string;
19
61
  locusUrl?: string;
20
62
  datachannelUrl?: string;
21
- datachannelToken?: string;
63
+ ownerMeetingId?: string;
22
64
  }
23
65
  >;
24
66
  }
25
67
 
26
- export enum DataChannelTokenType {
27
- Default = 'llm-default-session',
28
- PracticeSession = 'llm-practice-session',
29
- }
30
-
31
68
  // eslint-disable-next-line import/prefer-default-export
32
- export type {ILLMChannel};
69
+ export type {ILLMChannel, DataChannelTokenKey};