@webex/webex-core 3.12.0-task-refactor.1 → 3.12.0-webex-services-ready.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.
Files changed (41) hide show
  1. package/dist/config.js +7 -0
  2. package/dist/config.js.map +1 -1
  3. package/dist/credentials-config.js +12 -0
  4. package/dist/credentials-config.js.map +1 -1
  5. package/dist/interceptors/redirect.js +1 -1
  6. package/dist/interceptors/redirect.js.map +1 -1
  7. package/dist/lib/batcher.js +23 -7
  8. package/dist/lib/batcher.js.map +1 -1
  9. package/dist/lib/credentials/credentials.js +48 -4
  10. package/dist/lib/credentials/credentials.js.map +1 -1
  11. package/dist/lib/credentials/token.js +1 -1
  12. package/dist/lib/services/service-url.js +11 -1
  13. package/dist/lib/services/service-url.js.map +1 -1
  14. package/dist/lib/services/services.js +501 -96
  15. package/dist/lib/services/services.js.map +1 -1
  16. package/dist/lib/services-v2/services-v2.js +426 -42
  17. package/dist/lib/services-v2/services-v2.js.map +1 -1
  18. package/dist/lib/services-v2/types.js.map +1 -1
  19. package/dist/plugins/logger.js +1 -1
  20. package/dist/webex-core.js +2 -2
  21. package/dist/webex-core.js.map +1 -1
  22. package/package.json +13 -13
  23. package/src/config.js +7 -0
  24. package/src/credentials-config.js +13 -0
  25. package/src/interceptors/redirect.js +4 -1
  26. package/src/lib/batcher.js +25 -10
  27. package/src/lib/credentials/credentials.js +50 -3
  28. package/src/lib/services/service-url.js +9 -1
  29. package/src/lib/services/services.js +368 -14
  30. package/src/lib/services-v2/services-v2.ts +353 -10
  31. package/src/lib/services-v2/types.ts +5 -0
  32. package/test/integration/spec/services/service-catalog.js +16 -11
  33. package/test/integration/spec/services/services.js +38 -11
  34. package/test/integration/spec/services-v2/services-v2.js +29 -8
  35. package/test/unit/spec/credentials/credentials.js +133 -2
  36. package/test/unit/spec/lib/batcher.js +56 -0
  37. package/test/unit/spec/services/service-url.js +110 -0
  38. package/test/unit/spec/services/services.js +680 -80
  39. package/test/unit/spec/services-v2/services-v2.ts +484 -62
  40. package/test/unit/spec/webex-core.js +2 -0
  41. package/test/unit/spec/webex-internal-core.js +2 -0
@@ -1,3 +1,4 @@
1
+ /* eslint-disable no-console */
1
2
  import sha256 from 'crypto-js/sha256';
2
3
 
3
4
  import {union, forEach} from 'lodash';
@@ -20,6 +21,14 @@ export const DEFAULT_CLUSTER_SERVICE = 'identityLookup';
20
21
  const CLUSTER_SERVICE = process.env.WEBEX_CONVERSATION_CLUSTER_SERVICE || DEFAULT_CLUSTER_SERVICE;
21
22
  const DEFAULT_CLUSTER_IDENTIFIER =
22
23
  process.env.WEBEX_CONVERSATION_DEFAULT_CLUSTER || `${DEFAULT_CLUSTER}:${CLUSTER_SERVICE}`;
24
+ const CATALOG_CACHE_KEY_V1 = 'services.v1.u2cHostMap';
25
+ const CATALOG_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
26
+
27
+ // Maximum time we will wait for the initial catalog collection before letting
28
+ // `services.ready` (and therefore `webex.ready`) fire anyway. A hung request
29
+ // must never leave the app on a permanent spinner - past this point downstream
30
+ // consumers must fall through to their normal error/login paths.
31
+ const SERVICES_INIT_TIMEOUT_MS = 15_000;
23
32
 
24
33
  /* eslint-disable no-underscore-dangle */
25
34
  /**
@@ -55,6 +64,20 @@ const Services = WebexPlugin.extend({
55
64
  initFailed: ['boolean', false, false],
56
65
  },
57
66
 
67
+ session: {
68
+ /**
69
+ * Becomes `true` once service catalog initialization has completed.
70
+ * Blocks `webex.ready` until services are initialized.
71
+ * @instance
72
+ * @memberof Services
73
+ * @type {boolean}
74
+ */
75
+ ready: {
76
+ default: false,
77
+ type: 'boolean',
78
+ },
79
+ },
80
+
58
81
  _catalogs: new WeakMap(),
59
82
 
60
83
  _serviceUrls: null,
@@ -96,6 +119,49 @@ const Services = WebexPlugin.extend({
96
119
  return this._catalogs.get(this.webex);
97
120
  },
98
121
 
122
+ /**
123
+ * Safely access localStorage if available; returns the Storage or null.
124
+ * @returns {Storage|null}
125
+ */
126
+ _getLocalStorageSafe() {
127
+ if (typeof window !== 'undefined' && window.localStorage) {
128
+ return window.localStorage;
129
+ }
130
+
131
+ return null;
132
+ },
133
+
134
+ /**
135
+ * Determine the intended preauth selection based on the current context.
136
+ * @param {string|undefined} currentOrgId
137
+ * @returns {{selectionType: string, selectionValue: string}}
138
+ */
139
+ getIntendedPreauthSelection(currentOrgId) {
140
+ if (this.webex.credentials?.canAuthorize) {
141
+ if (currentOrgId) {
142
+ return {
143
+ selectionType: 'orgId',
144
+ selectionValue: currentOrgId,
145
+ };
146
+ }
147
+ }
148
+
149
+ const emailConfig = this.webex.config && this.webex.config.email;
150
+
151
+ if (typeof emailConfig === 'string' && emailConfig.trim()) {
152
+ return {
153
+ selectionType: 'emailhash',
154
+ selectionValue: sha256(emailConfig.toLowerCase()).toString(),
155
+ };
156
+ }
157
+
158
+ // fall back to proximity mode when no orgId or email available
159
+ return {
160
+ selectionType: 'mode',
161
+ selectionValue: 'DEFAULT_BY_PROXIMITY',
162
+ };
163
+ },
164
+
99
165
  /**
100
166
  * Get a service url from the current services list by name
101
167
  * from the associated instance catalog.
@@ -165,9 +231,10 @@ const Services = WebexPlugin.extend({
165
231
 
166
232
  /**
167
233
  * Get all Mobius cluster host entries from the legacy host catalog.
168
- * @returns {Array<Object>}
234
+ * @returns {Array<{host: string, id: string, ttl: number, priority: number}>}
169
235
  */
170
236
  getMobiusClusters() {
237
+ this.logger.info('services: fetching mobius clusters');
171
238
  const clusters = [];
172
239
  const hostCatalog = this._hostCatalog || {};
173
240
 
@@ -197,6 +264,28 @@ const Services = WebexPlugin.extend({
197
264
 
198
265
  return !!hostCatalog[host]?.length;
199
266
  },
267
+
268
+ /**
269
+ * Checks if the current environment is an integration (INT) environment
270
+ * by examining the u2c discovery URL from webex config.
271
+ * INT environments use discovery URLs containing 'intb' (e.g., u2c-intb.ciscospark.com).
272
+ * @returns {boolean} True if INT environment, false otherwise
273
+ */
274
+ isIntegrationEnvironment() {
275
+ try {
276
+ const u2cUrl = this.webex?.config?.services?.discovery?.u2c || '';
277
+ const isInt = u2cUrl.includes('intb');
278
+
279
+ this.logger.info(`services: isIntegrationEnvironment: ${isInt}`);
280
+
281
+ return isInt;
282
+ } catch (error) {
283
+ this.logger.error('services: failed to determine integration environment', error);
284
+
285
+ return false;
286
+ }
287
+ },
288
+
200
289
  /**
201
290
  * Merge provided active cluster mappings into current state.
202
291
  * @param {Record<string,string>} activeServices
@@ -237,7 +326,7 @@ const Services = WebexPlugin.extend({
237
326
  * @param {string} [param.token] - used for signin catalog
238
327
  * @returns {Promise<object>}
239
328
  */
240
- updateServices({from, query, token, forceRefresh} = {}) {
329
+ async updateServices({from, query, token, forceRefresh} = {}) {
241
330
  const catalog = this._getCatalog();
242
331
  let formattedQuery;
243
332
  let serviceGroup;
@@ -291,7 +380,20 @@ const Services = WebexPlugin.extend({
291
380
  forceRefresh,
292
381
  })
293
382
  .then((serviceHostMap) => {
294
- catalog.updateServiceUrls(serviceGroup, serviceHostMap);
383
+ const formattedServiceHostMap = this._formatReceivedHostmap(serviceHostMap);
384
+ // Build selection metadata for caching discrimination
385
+ let selectionMeta;
386
+ if (serviceGroup === 'preauth' || serviceGroup === 'signin') {
387
+ const key = formattedQuery && Object.keys(formattedQuery || {})[0];
388
+ if (key) {
389
+ selectionMeta = {
390
+ selectionType: key,
391
+ selectionValue: formattedQuery[key],
392
+ };
393
+ }
394
+ }
395
+ this._cacheCatalog(serviceGroup, serviceHostMap, selectionMeta);
396
+ catalog.updateServiceUrls(serviceGroup, formattedServiceHostMap);
295
397
  this.updateCredentialsConfig();
296
398
  catalog.status[serviceGroup].collecting = false;
297
399
  })
@@ -1008,7 +1110,197 @@ const Services = WebexPlugin.extend({
1008
1110
 
1009
1111
  return this.webex.internal.newMetrics.callDiagnosticLatencies
1010
1112
  .measureLatency(() => this.request(requestObject), 'internal.get.u2c.time')
1011
- .then(({body}) => this._formatReceivedHostmap(body));
1113
+ .then(({body}) => body);
1114
+ },
1115
+
1116
+ /**
1117
+ * Cache the catalog in the bounded storage.
1118
+ * @param {string} serviceGroup - preauth, signin, postauth
1119
+ * @param {object} hostMap - The hostmap to cache
1120
+ * @param {object} [meta] - Optional selection metadata used to validate cache reuse
1121
+ * @returns {Promise<void>}
1122
+ *
1123
+ */
1124
+ async _cacheCatalog(serviceGroup, hostMap, meta) {
1125
+ let current = {};
1126
+ let orgId;
1127
+ try {
1128
+ // Respect calling.cacheU2C toggle; if disabled, skip writing cache
1129
+ if (!this.webex.config?.calling?.cacheU2C) {
1130
+ this.logger.info(`services: skipping cache write for ${serviceGroup} as per the config`);
1131
+
1132
+ return;
1133
+ }
1134
+
1135
+ // Persist to localStorage to survive browser refresh
1136
+ try {
1137
+ const ls = this._getLocalStorageSafe();
1138
+ const cachedJson = ls ? ls.getItem(CATALOG_CACHE_KEY_V1) : null;
1139
+ current = cachedJson ? JSON.parse(cachedJson) : {};
1140
+ } catch (e) {
1141
+ current = {};
1142
+ }
1143
+
1144
+ try {
1145
+ const {credentials} = this.webex;
1146
+ orgId = credentials.getOrgId();
1147
+ } catch (e) {
1148
+ orgId = current.orgId;
1149
+ }
1150
+
1151
+ // Capture environment fingerprint to invalidate cache across env changes
1152
+ let {env} = current;
1153
+ const fedramp = !!this.webex?.config?.fedramp;
1154
+ const u2cDiscoveryUrl = this.webex?.config?.services?.discovery?.u2c;
1155
+ env = {fedramp, u2cDiscoveryUrl};
1156
+
1157
+ const updated = {
1158
+ ...current,
1159
+ orgId: orgId || current.orgId,
1160
+ env: env || current.env,
1161
+ // When selection meta is provided, store as an object; otherwise keep legacy shape
1162
+ [serviceGroup]: meta ? {hostMap, meta} : hostMap,
1163
+ cachedAt: Date.now(),
1164
+ };
1165
+
1166
+ const ls = this._getLocalStorageSafe();
1167
+ if (ls) {
1168
+ ls.setItem(CATALOG_CACHE_KEY_V1, JSON.stringify(updated));
1169
+ }
1170
+ } catch (error) {
1171
+ this.logger.warn('services: error caching catalog', error);
1172
+ }
1173
+ },
1174
+
1175
+ /**
1176
+ * Load the catalog from cache and hydrate the in-memory ServiceCatalog.
1177
+ * @returns {Promise<boolean>} true if cache was loaded, false otherwise
1178
+ */
1179
+ async _loadCatalogFromCache() {
1180
+ let currentOrgId;
1181
+ try {
1182
+ // Respect calling.cacheU2C toggle; if disabled, skip using cache
1183
+ if (!this.webex.config?.calling?.cacheU2C) {
1184
+ this.logger.info('services: skipping cache warm-up as per the cache config');
1185
+
1186
+ return false;
1187
+ }
1188
+
1189
+ const ls = this._getLocalStorageSafe();
1190
+ if (!ls) {
1191
+ this.logger.info('services: skipping cache warm-up as no localStorage is available');
1192
+
1193
+ return false;
1194
+ }
1195
+ const cachedJson = ls.getItem(CATALOG_CACHE_KEY_V1);
1196
+ const cached = cachedJson ? JSON.parse(cachedJson) : undefined;
1197
+ if (!cached) {
1198
+ return false;
1199
+ }
1200
+ // TTL enforcement: clear if older than 24 hours
1201
+ const cachedAt = Number(cached.cachedAt) || 0;
1202
+ if (!cachedAt || Date.now() - cachedAt > CATALOG_TTL_MS) {
1203
+ this.clearCatalogCache();
1204
+
1205
+ return false;
1206
+ }
1207
+
1208
+ // If authorized, ensure cached org matches
1209
+ try {
1210
+ if (this.webex.credentials?.canAuthorize) {
1211
+ const {credentials} = this.webex;
1212
+ currentOrgId = credentials.getOrgId();
1213
+ if (cached.orgId && cached.orgId !== currentOrgId) {
1214
+ return false;
1215
+ }
1216
+ }
1217
+ } catch (e) {
1218
+ this.logger.warn('services: error checking orgId', e);
1219
+ }
1220
+
1221
+ // Ensure cached environment matches current environment
1222
+
1223
+ const fedramp = !!this.webex.config?.fedramp;
1224
+ const u2cDiscoveryUrl = this.webex.config?.services?.discovery?.u2c;
1225
+ const currentEnv = {fedramp, u2cDiscoveryUrl};
1226
+ if (cached.env) {
1227
+ const sameEnv =
1228
+ cached.env.fedramp === currentEnv.fedramp &&
1229
+ cached.env.u2cDiscoveryUrl === currentEnv.u2cDiscoveryUrl;
1230
+ if (!sameEnv) {
1231
+ this.logger.info('services: skipping cache warm due to environment mismatch');
1232
+
1233
+ return false;
1234
+ }
1235
+ }
1236
+
1237
+ const catalog = this._getCatalog();
1238
+
1239
+ // Apply any cached groups (with preauth selection validation if available)
1240
+ const groups = ['preauth', 'signin', 'postauth'];
1241
+ groups.forEach((serviceGroup) => {
1242
+ const cachedGroup = cached[serviceGroup];
1243
+ if (!cachedGroup) {
1244
+ return;
1245
+ }
1246
+
1247
+ // Support legacy (hostMap) and new ({hostMap, meta}) shapes
1248
+ const hostMap = cachedGroup && cachedGroup.hostMap ? cachedGroup.hostMap : cachedGroup;
1249
+ const meta = cachedGroup?.meta;
1250
+
1251
+ if (serviceGroup === 'preauth' && meta) {
1252
+ // For proximity-based selection, always fetch fresh to respect IP/region changes
1253
+ if (meta.selectionType === 'mode') {
1254
+ this.logger.info('services: skipping preauth cache warm for proximity mode');
1255
+
1256
+ return;
1257
+ }
1258
+
1259
+ const intended = this.getIntendedPreauthSelection(currentOrgId);
1260
+ const matches =
1261
+ intended &&
1262
+ intended.selectionType === meta.selectionType &&
1263
+ intended.selectionValue === meta.selectionValue;
1264
+
1265
+ if (!matches) {
1266
+ this.logger.info('services: skipping preauth cache warm due to selection mismatch');
1267
+
1268
+ return;
1269
+ }
1270
+ }
1271
+
1272
+ if (hostMap) {
1273
+ const formatted = this._formatReceivedHostmap(hostMap);
1274
+ catalog.updateServiceUrls(serviceGroup, formatted);
1275
+ }
1276
+ });
1277
+
1278
+ // Align credentials against warmed catalog
1279
+ this.updateCredentialsConfig();
1280
+
1281
+ return true;
1282
+ } catch (e) {
1283
+ this.logger.warn('services: error loading catalog from cache', e);
1284
+
1285
+ return false;
1286
+ }
1287
+ },
1288
+
1289
+ /**
1290
+ * Clear the catalog cache from the bounded storage.
1291
+ * @returns {Promise<void>}
1292
+ */
1293
+ clearCatalogCache() {
1294
+ try {
1295
+ const ls = this._getLocalStorageSafe();
1296
+ if (ls) {
1297
+ ls.removeItem(CATALOG_CACHE_KEY_V1);
1298
+ }
1299
+ } catch (e) {
1300
+ this.logger.warn('services: error clearing catalog cache', e);
1301
+ }
1302
+
1303
+ return Promise.resolve();
1012
1304
  },
1013
1305
 
1014
1306
  /**
@@ -1088,6 +1380,7 @@ const Services = WebexPlugin.extend({
1088
1380
  // Validate if the token is authorized.
1089
1381
  if (credentials.canAuthorize) {
1090
1382
  // Attempt to collect the postauth catalog.
1383
+
1091
1384
  return this.updateServices().catch(() => {
1092
1385
  this.initFailed = true;
1093
1386
  this.logger.warn('services: cannot retrieve postauth catalog');
@@ -1100,6 +1393,30 @@ const Services = WebexPlugin.extend({
1100
1393
  );
1101
1394
  },
1102
1395
 
1396
+ /**
1397
+ * Await any in-flight credentials refresh, then flip `services.ready` so
1398
+ * `webex.ready` can fire. Closes the parallel-refresh window: if a credential
1399
+ * refresh is in flight when initial catalog collection settles, we must not
1400
+ * signal ready until the refresh has resolved - otherwise downstream
1401
+ * consumers may observe `canAuthorize`/token state that is about to change
1402
+ * under them.
1403
+ *
1404
+ * @private
1405
+ * @returns {Promise<void>}
1406
+ */
1407
+ async _finalizeReady() {
1408
+ const {credentials} = this.webex;
1409
+
1410
+ if (credentials && credentials.isRefreshing) {
1411
+ await new Promise((resolve) => {
1412
+ credentials.once('change:isRefreshing', resolve);
1413
+ });
1414
+ }
1415
+
1416
+ this.ready = true;
1417
+ this.trigger('services:initialized');
1418
+ },
1419
+
1103
1420
  /**
1104
1421
  * Initializer
1105
1422
  *
@@ -1121,13 +1438,32 @@ const Services = WebexPlugin.extend({
1121
1438
  this.initConfig();
1122
1439
  });
1123
1440
 
1124
- // wait for webex instance to be ready before attempting
1125
- // to update the service catalogs
1126
- this.listenToOnce(this.webex, 'ready', () => {
1441
+ // Wait for storage to be loaded before attempting to update the service catalogs.
1442
+ // We listen for 'loaded' instead of 'ready' because services.ready is a dependency
1443
+ // of webex.ready - listening to 'ready' would cause a deadlock.
1444
+ this.listenToOnce(this.webex, 'loaded', async () => {
1445
+ const cachedCatalog = await this._loadCatalogFromCache();
1446
+ if (cachedCatalog) {
1447
+ catalog.isReady = true;
1448
+ await this._finalizeReady();
1449
+
1450
+ return; // skip initServiceCatalogs() on reload when cache exists
1451
+ }
1127
1452
  const {supertoken} = this.webex.credentials;
1453
+
1454
+ // Race init against a hard timeout so a hung request never leaves
1455
+ // `services.ready` false forever - that would stall `webex.ready` and
1456
+ // leave the app on a permanent spinner.
1457
+ const timeout = new Promise((_, reject) => {
1458
+ setTimeout(
1459
+ () => reject(new Error(`services: init timed out after ${SERVICES_INIT_TIMEOUT_MS}ms`)),
1460
+ SERVICES_INIT_TIMEOUT_MS
1461
+ );
1462
+ });
1463
+
1128
1464
  // Validate if the supertoken exists.
1129
1465
  if (supertoken && supertoken.access_token) {
1130
- this.initServiceCatalogs()
1466
+ Promise.race([this.initServiceCatalogs(), timeout])
1131
1467
  .then(() => {
1132
1468
  catalog.isReady = true;
1133
1469
  })
@@ -1136,15 +1472,33 @@ const Services = WebexPlugin.extend({
1136
1472
  this.logger.error(
1137
1473
  `services: failed to init initial services when credentials available, ${error?.message}`
1138
1474
  );
1139
- });
1475
+ })
1476
+ .finally(() => this._finalizeReady());
1140
1477
  } else {
1141
1478
  const {email} = this.webex.config;
1142
1479
 
1143
- this.collectPreauthCatalog(email ? {email} : undefined).catch((error) => {
1144
- this.initFailed = true;
1145
- this.logger.error(
1146
- `services: failed to init initial services when no credentials available, ${error?.message}`
1147
- );
1480
+ Promise.race([this.collectPreauthCatalog(email ? {email} : undefined), timeout])
1481
+ .catch((error) => {
1482
+ this.initFailed = true;
1483
+ this.logger.error(
1484
+ `services: failed to init initial services when no credentials available, ${error?.message}`
1485
+ );
1486
+ })
1487
+ .finally(() => this._finalizeReady());
1488
+ // Listen for when credentials become available to fetch the full catalog.
1489
+ // This handles fresh login where 'loaded' fires before OAuth completes.
1490
+ this.listenToOnce(this.webex, 'change:canAuthorize', () => {
1491
+ if (this.webex.canAuthorize && !catalog.status.postauth.ready) {
1492
+ this.initServiceCatalogs()
1493
+ .then(() => {
1494
+ catalog.isReady = true;
1495
+ })
1496
+ .catch((error) => {
1497
+ this.logger.error(
1498
+ `services: failed to init service catalogs after auth, ${error?.message}`
1499
+ );
1500
+ });
1501
+ }
1148
1502
  });
1149
1503
  }
1150
1504
  });