@webex/webex-core 3.12.0-webex-services-ready.1 → 3.12.0-webex-services-ready.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,4 +1,3 @@
1
- /* eslint-disable no-console */
2
1
  import sha256 from 'crypto-js/sha256';
3
2
 
4
3
  import {union, forEach} from 'lodash';
@@ -66,8 +65,10 @@ const Services = WebexPlugin.extend({
66
65
 
67
66
  session: {
68
67
  /**
69
- * Becomes `true` once service catalog initialization has completed.
70
- * Blocks `webex.ready` until services are initialized.
68
+ * Becomes `true` once the initial catalog collection has completed
69
+ * (successfully or otherwise) and any in-flight credentials refresh has
70
+ * settled. Blocks `webex.ready` so consumers can rely on `webex.ready`
71
+ * implying "catalogs populated AND credential state stable".
71
72
  * @instance
72
73
  * @memberof Services
73
74
  * @type {boolean}
@@ -1414,7 +1415,6 @@ const Services = WebexPlugin.extend({
1414
1415
  }
1415
1416
 
1416
1417
  this.ready = true;
1417
- this.trigger('services:initialized');
1418
1418
  },
1419
1419
 
1420
1420
  /**
@@ -1433,14 +1433,90 @@ const Services = WebexPlugin.extend({
1433
1433
  this.registries.set(this.webex, registry);
1434
1434
  this.states.set(this.webex, state);
1435
1435
 
1436
- // Listen for configuration changes once.
1436
+ // Listen for configuration changes once. The config is not populated on the
1437
+ // webex instance until the `change:config` event fires, so any decision that
1438
+ // depends on config values (such as the gated-vs-ungated init below) must be
1439
+ // made from within this handler rather than synchronously in `initialize()`.
1437
1440
  this.listenToOnce(this.webex, 'change:config', () => {
1438
1441
  this.initConfig();
1442
+
1443
+ // Feature flag: when enabled, `webex.ready` is blocked until the initial
1444
+ // catalog collection has settled AND any in-flight credentials refresh has
1445
+ // completed. When disabled (the default), preserves the pre-existing
1446
+ // behavior where `webex.ready` fires as soon as `webex.loaded` does and
1447
+ // the catalog is collected out-of-band.
1448
+ const waitForCatalogInit = this.webex.config?.services?.waitForCatalogInit === true;
1449
+
1450
+ if (waitForCatalogInit) {
1451
+ this._initializeCatalogsGated(catalog);
1452
+ } else {
1453
+ // Not gating - immediately mark ready so we do not block webex.ready.
1454
+ this.ready = true;
1455
+ this._initializeCatalogsUngated(catalog);
1456
+ }
1439
1457
  });
1458
+ },
1459
+
1460
+ /**
1461
+ * Original (pre-verified-ready) initialization path. Runs on `webex.ready`
1462
+ * and collects catalogs opportunistically without blocking anything.
1463
+ *
1464
+ * @private
1465
+ * @param {ServiceCatalog} catalog
1466
+ * @returns {void}
1467
+ */
1468
+ _initializeCatalogsUngated(catalog) {
1469
+ // wait for webex instance to be ready before attempting
1470
+ // to update the service catalogs
1471
+ // this can cause a race condition because credentials may
1472
+ // not be valid when services is initialized
1473
+ this.listenToOnce(this.webex, 'ready', async () => {
1474
+ const cachedCatalog = await this._loadCatalogFromCache();
1475
+ if (cachedCatalog) {
1476
+ catalog.isReady = true;
1440
1477
 
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.
1478
+ return; // skip initServiceCatalogs() on reload when cache exists
1479
+ }
1480
+ const {supertoken} = this.webex.credentials;
1481
+ // Validate if the supertoken exists.
1482
+ if (supertoken && supertoken.access_token) {
1483
+ this.initServiceCatalogs()
1484
+ .then(() => {
1485
+ catalog.isReady = true;
1486
+ })
1487
+ .catch((error) => {
1488
+ this.initFailed = true;
1489
+ this.logger.error(
1490
+ `services: failed to init initial services when credentials available, ${error?.message}`
1491
+ );
1492
+ });
1493
+ } else {
1494
+ const {email} = this.webex.config;
1495
+
1496
+ this.collectPreauthCatalog(email ? {email} : undefined).catch((error) => {
1497
+ this.initFailed = true;
1498
+ this.logger.error(
1499
+ `services: failed to init initial services when no credentials available, ${error?.message}`
1500
+ );
1501
+ });
1502
+ }
1503
+ });
1504
+ },
1505
+
1506
+ /**
1507
+ * Verified-ready initialization path. Blocks `webex.ready` until the initial
1508
+ * catalog fetch has settled (or timed out) AND any in-flight credentials
1509
+ * refresh has completed. Also handles the fresh-login case where OAuth
1510
+ * completes after `loaded` fires.
1511
+ *
1512
+ * @private
1513
+ * @param {ServiceCatalog} catalog
1514
+ * @returns {void}
1515
+ */
1516
+ _initializeCatalogsGated(catalog) {
1517
+ // Wait for storage to be loaded before attempting to update the service
1518
+ // catalogs. We listen for 'loaded' instead of 'ready' because `services.ready`
1519
+ // now blocks `webex.ready` - listening to 'ready' would deadlock.
1444
1520
  this.listenToOnce(this.webex, 'loaded', async () => {
1445
1521
  const cachedCatalog = await this._loadCatalogFromCache();
1446
1522
  if (cachedCatalog) {
@@ -1485,19 +1561,16 @@ const Services = WebexPlugin.extend({
1485
1561
  );
1486
1562
  })
1487
1563
  .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.
1564
+
1565
+ // Handle fresh login: 'loaded' fires before OAuth completes, so listen
1566
+ // for `canAuthorize` flipping true and then collect the postauth catalog.
1490
1567
  this.listenToOnce(this.webex, 'change:canAuthorize', () => {
1491
1568
  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
- });
1569
+ this.initServiceCatalogs().catch((error) => {
1570
+ this.logger.error(
1571
+ `services: failed to init service catalogs after auth, ${error?.message}`
1572
+ );
1573
+ });
1501
1574
  }
1502
1575
  });
1503
1576
  }
@@ -52,8 +52,10 @@ const Services = WebexPlugin.extend({
52
52
 
53
53
  session: {
54
54
  /**
55
- * Becomes `true` once services initialization has completed.
56
- * This blocks `webex.ready` until services are initialized.
55
+ * Becomes `true` once the initial catalog collection has completed
56
+ * (successfully or otherwise) and any in-flight credentials refresh has
57
+ * settled. Blocks `webex.ready` so consumers can rely on `webex.ready`
58
+ * implying "catalogs populated AND credential state stable".
57
59
  * @instance
58
60
  * @memberof Services
59
61
  * @type {boolean}
@@ -1378,7 +1380,6 @@ const Services = WebexPlugin.extend({
1378
1380
  }
1379
1381
 
1380
1382
  this.ready = true;
1381
- this.trigger('services:initialized');
1382
1383
  },
1383
1384
 
1384
1385
  /**
@@ -1392,14 +1393,88 @@ const Services = WebexPlugin.extend({
1392
1393
  const catalog = new ServiceCatalog();
1393
1394
  this._catalogs.set(this.webex, catalog);
1394
1395
 
1395
- // Listen for configuration changes once.
1396
+ // Listen for configuration changes once. The config is not populated on the
1397
+ // webex instance until the `change:config` event fires, so any decision that
1398
+ // depends on config values (such as the gated-vs-ungated init below) must be
1399
+ // made from within this handler rather than synchronously in `initialize()`.
1396
1400
  this.listenToOnce(this.webex, 'change:config', () => {
1397
1401
  this.initConfig();
1402
+
1403
+ // Feature flag: when enabled, `webex.ready` is blocked until the initial
1404
+ // catalog collection has settled AND any in-flight credentials refresh has
1405
+ // completed. When disabled (the default), preserves the pre-existing
1406
+ // behavior where `webex.ready` fires as soon as `webex.loaded` does and
1407
+ // the catalog is collected out-of-band.
1408
+ const waitForCatalogInit = this.webex.config?.services?.waitForCatalogInit === true;
1409
+
1410
+ if (waitForCatalogInit) {
1411
+ this._initializeCatalogsGated(catalog);
1412
+ } else {
1413
+ // Not gating - immediately mark ready so we do not block webex.ready.
1414
+ this.ready = true;
1415
+ this._initializeCatalogsUngated(catalog);
1416
+ }
1398
1417
  });
1418
+ },
1419
+
1420
+ /**
1421
+ * Original (pre-verified-ready) initialization path. Runs on `webex.ready`
1422
+ * and collects catalogs opportunistically without blocking anything.
1423
+ *
1424
+ * @private
1425
+ * @param {ServiceCatalog} catalog
1426
+ * @returns {void}
1427
+ */
1428
+ _initializeCatalogsUngated(catalog: ServiceCatalog): void {
1429
+ // wait for webex instance to be ready before attempting
1430
+ // to update the service catalogs
1431
+ this.listenToOnce(this.webex, 'ready', async () => {
1432
+ const warmed = await this._loadCatalogFromCache();
1433
+ if (warmed) {
1434
+ catalog.isReady = true;
1399
1435
 
1400
- // Wait for storage to be loaded before attempting to update the service catalogs.
1401
- // We listen for 'loaded' instead of 'ready' because services.ready is a dependency
1402
- // of webex.ready - listening to 'ready' would cause a deadlock.
1436
+ return;
1437
+ }
1438
+ const {supertoken} = this.webex.credentials;
1439
+ // Validate if the supertoken exists.
1440
+ if (supertoken && supertoken.access_token) {
1441
+ this.initServiceCatalogs()
1442
+ .then(() => {
1443
+ catalog.isReady = true;
1444
+ })
1445
+ .catch((error) => {
1446
+ this.initFailed = true;
1447
+ this.logger.error(
1448
+ `services: failed to init initial services when credentials available, ${error?.message}`
1449
+ );
1450
+ });
1451
+ } else {
1452
+ const {email} = this.webex.config;
1453
+
1454
+ this.collectPreauthCatalog(email ? {email} : undefined).catch((error) => {
1455
+ this.initFailed = true;
1456
+ this.logger.error(
1457
+ `services: failed to init initial services when no credentials available, ${error?.message}`
1458
+ );
1459
+ });
1460
+ }
1461
+ });
1462
+ },
1463
+
1464
+ /**
1465
+ * Verified-ready initialization path. Blocks `webex.ready` until the initial
1466
+ * catalog fetch has settled (or timed out) AND any in-flight credentials
1467
+ * refresh has completed. Also handles the fresh-login case where OAuth
1468
+ * completes after `loaded` fires.
1469
+ *
1470
+ * @private
1471
+ * @param {ServiceCatalog} catalog
1472
+ * @returns {void}
1473
+ */
1474
+ _initializeCatalogsGated(catalog: ServiceCatalog): void {
1475
+ // Wait for storage to be loaded before attempting to update the service
1476
+ // catalogs. We listen for 'loaded' instead of 'ready' because `services.ready`
1477
+ // now blocks `webex.ready` - listening to 'ready' would deadlock.
1403
1478
  this.listenToOnce(this.webex, 'loaded', async () => {
1404
1479
  const warmed = await this._loadCatalogFromCache();
1405
1480
  if (warmed) {
@@ -1444,19 +1519,16 @@ const Services = WebexPlugin.extend({
1444
1519
  );
1445
1520
  })
1446
1521
  .finally(() => this._finalizeReady());
1447
- // Listen for when credentials become available to fetch the full catalog.
1448
- // This handles fresh login where 'loaded' fires before OAuth completes.
1522
+
1523
+ // Handle fresh login: 'loaded' fires before OAuth completes, so listen
1524
+ // for `canAuthorize` flipping true and then collect the postauth catalog.
1449
1525
  this.listenToOnce(this.webex, 'change:canAuthorize', () => {
1450
1526
  if (this.webex.canAuthorize && !catalog.status.postauth.ready) {
1451
- this.initServiceCatalogs()
1452
- .then(() => {
1453
- catalog.isReady = true;
1454
- })
1455
- .catch((error) => {
1456
- this.logger.error(
1457
- `services: failed to init service catalogs after auth, ${error?.message}`
1458
- );
1459
- });
1527
+ this.initServiceCatalogs().catch((error) => {
1528
+ this.logger.error(
1529
+ `services: failed to init service catalogs after auth, ${error?.message}`
1530
+ );
1531
+ });
1460
1532
  }
1461
1533
  });
1462
1534
  }
@@ -404,26 +404,15 @@ describe('webex-core', () => {
404
404
  assert.isTrue(catalog.isReady);
405
405
  });
406
406
 
407
- it('should call services#initServiceCatalogs() on webex loaded', async () => {
407
+ it('should call services#initServiceCatalogs() on webex ready', async () => {
408
+ services._loadCatalogFromCache = sinon.stub().resolves(false);
408
409
  services.initServiceCatalogs = sinon.stub().resolves();
409
410
  services.initialize();
410
- webex.trigger('loaded');
411
- // Wait for the async callback to execute
412
- await new Promise((resolve) => setTimeout(resolve, 10));
411
+ webex.trigger('ready');
412
+ // Wait for the async 'ready' handler to complete
413
+ await new Promise((resolve) => setTimeout(resolve, 50));
413
414
  assert.called(services.initServiceCatalogs);
414
- });
415
-
416
- it('should set services.ready to true after initialization completes', async () => {
417
- // services.ready starts as false
418
- const newWebex = new WebexCore({credentials: {supertoken: webexUser.token}});
419
- const newServices = newWebex.internal.services;
420
-
421
- // Wait for initialization to complete
422
- await new Promise((resolve) => {
423
- newServices.on('services:initialized', resolve);
424
- });
425
-
426
- assert.isTrue(newServices.ready);
415
+ assert.isTrue(catalog.isReady);
427
416
  });
428
417
 
429
418
  it('should collect different catalogs based on OrgId region', () =>
@@ -441,6 +430,39 @@ describe('webex-core', () => {
441
430
  done();
442
431
  }, 2000);
443
432
  });
433
+
434
+ it('blocks webex.ready until services.ready flips when waitForCatalogInit is enabled', async () => {
435
+ const gatedWebex = new WebexCore({
436
+ credentials: {supertoken: webexUser.token},
437
+ config: {services: {waitForCatalogInit: true}},
438
+ });
439
+
440
+ // Before init settles, webex.ready must be false because services.ready
441
+ // is a dependency and starts false in the gated path.
442
+ assert.isFalse(gatedWebex.internal.services.ready, 'services.ready should start false');
443
+ assert.isFalse(gatedWebex.ready, 'webex.ready should not fire while services.ready is false');
444
+
445
+ // Wait up to 30s for services init to complete and flip ready.
446
+ await new Promise((resolve, reject) => {
447
+ if (gatedWebex.internal.services.ready) {
448
+ resolve();
449
+
450
+ return;
451
+ }
452
+ const timer = setTimeout(
453
+ () => reject(new Error('timed out waiting for services.ready')),
454
+ 30_000
455
+ );
456
+
457
+ gatedWebex.internal.services.once('change:ready', () => {
458
+ clearTimeout(timer);
459
+ resolve();
460
+ });
461
+ });
462
+
463
+ assert.isTrue(gatedWebex.internal.services.ready, 'services.ready should flip true after init settles');
464
+ assert.isTrue(gatedWebex.ready, 'webex.ready should fire once services.ready flips');
465
+ });
444
466
  });
445
467
 
446
468
  describe('#initServiceCatalogs()', () => {
@@ -316,26 +316,15 @@ describe('webex-core', () => {
316
316
  assert.isTrue(catalog.isReady);
317
317
  });
318
318
 
319
- it('should call services#initServiceCatalogs() on webex loaded', async () => {
319
+ it('should call services#initServiceCatalogs() on webex ready', async () => {
320
+ services._loadCatalogFromCache = sinon.stub().resolves(false);
320
321
  services.initServiceCatalogs = sinon.stub().resolves();
321
322
  services.initialize();
322
- webex.trigger('loaded');
323
- // Wait for the async callback to execute
324
- await new Promise((resolve) => setTimeout(resolve, 10));
323
+ webex.trigger('ready');
324
+ // Wait for the async 'ready' handler to complete
325
+ await new Promise((resolve) => setTimeout(resolve, 50));
325
326
  assert.called(services.initServiceCatalogs);
326
- });
327
-
328
- it('should set services.ready to true after initialization completes', async () => {
329
- // services.ready starts as false
330
- const newWebex = new WebexCore({credentials: {supertoken: webexUser.token}});
331
- const newServices = newWebex.internal.services;
332
-
333
- // Wait for initialization to complete
334
- await new Promise((resolve) => {
335
- newServices.on('services:initialized', resolve);
336
- });
337
-
338
- assert.isTrue(newServices.ready);
327
+ assert.isTrue(catalog.isReady);
339
328
  });
340
329
 
341
330
  it('should collect different catalogs based on OrgId region', () =>
@@ -352,6 +341,39 @@ describe('webex-core', () => {
352
341
  done();
353
342
  }, 2000);
354
343
  });
344
+
345
+ it('blocks webex.ready until services.ready flips when waitForCatalogInit is enabled', async () => {
346
+ const gatedWebex = new WebexCore({
347
+ credentials: {supertoken: webexUser.token},
348
+ config: {services: {waitForCatalogInit: true}},
349
+ });
350
+
351
+ // Before init settles, webex.ready must be false because services.ready
352
+ // is a dependency and starts false in the gated path.
353
+ assert.isFalse(gatedWebex.internal.services.ready, 'services.ready should start false');
354
+ assert.isFalse(gatedWebex.ready, 'webex.ready should not fire while services.ready is false');
355
+
356
+ // Wait up to 30s for services init to complete and flip ready.
357
+ await new Promise((resolve, reject) => {
358
+ if (gatedWebex.internal.services.ready) {
359
+ resolve();
360
+
361
+ return;
362
+ }
363
+ const timer = setTimeout(
364
+ () => reject(new Error('timed out waiting for services.ready')),
365
+ 30_000
366
+ );
367
+
368
+ gatedWebex.internal.services.once('change:ready', () => {
369
+ clearTimeout(timer);
370
+ resolve();
371
+ });
372
+ });
373
+
374
+ assert.isTrue(gatedWebex.internal.services.ready, 'services.ready should flip true after init settles');
375
+ assert.isTrue(gatedWebex.ready, 'webex.ready should fire once services.ready flips');
376
+ });
355
377
  });
356
378
 
357
379
  describe('#initServiceCatalogs()', () => {