@xh/hoist 72.1.0 → 72.3.0

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 (60) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/admin/tabs/activity/clienterrors/ClientErrorsModel.ts +23 -4
  3. package/admin/tabs/cluster/instances/InstancesTabModel.ts +4 -3
  4. package/admin/tabs/cluster/instances/logs/LogDisplay.ts +12 -14
  5. package/admin/tabs/cluster/instances/logs/LogDisplayModel.ts +0 -2
  6. package/admin/tabs/cluster/instances/logs/LogViewer.ts +6 -5
  7. package/admin/tabs/cluster/instances/logs/LogViewerModel.ts +8 -1
  8. package/admin/tabs/cluster/instances/memory/MemoryMonitorModel.ts +1 -0
  9. package/admin/tabs/cluster/instances/services/DetailsModel.ts +1 -2
  10. package/admin/tabs/cluster/instances/services/DetailsPanel.ts +19 -14
  11. package/admin/tabs/cluster/instances/services/ServiceModel.ts +14 -6
  12. package/admin/tabs/cluster/instances/services/ServicePanel.ts +9 -10
  13. package/admin/tabs/cluster/instances/websocket/WebSocketColumns.ts +9 -0
  14. package/admin/tabs/cluster/instances/websocket/WebSocketModel.ts +2 -1
  15. package/admin/tabs/userData/roles/RoleModel.ts +1 -1
  16. package/admin/tabs/userData/roles/details/RoleDetailsModel.ts +2 -1
  17. package/admin/tabs/userData/roles/recategorize/RecategorizeDialogModel.ts +1 -1
  18. package/appcontainer/AppStateModel.ts +6 -1
  19. package/build/types/admin/tabs/activity/tracking/ActivityTrackingModel.d.ts +4 -1
  20. package/build/types/admin/tabs/cluster/instances/services/DetailsModel.d.ts +2 -3
  21. package/build/types/admin/tabs/cluster/instances/websocket/WebSocketColumns.d.ts +1 -0
  22. package/build/types/appcontainer/AppStateModel.d.ts +5 -1
  23. package/build/types/cmp/tab/TabContainerModel.d.ts +5 -5
  24. package/build/types/core/HoistProps.d.ts +1 -0
  25. package/build/types/core/XH.d.ts +5 -5
  26. package/build/types/core/types/Interfaces.d.ts +9 -0
  27. package/build/types/desktop/cmp/appOption/AutoRefreshAppOption.d.ts +1 -0
  28. package/build/types/desktop/cmp/appOption/ThemeAppOption.d.ts +1 -0
  29. package/build/types/desktop/cmp/tab/TabSwitcher.d.ts +1 -1
  30. package/build/types/kit/blueprint/Wrappers.d.ts +1 -1
  31. package/build/types/kit/swiper/index.d.ts +4 -4
  32. package/build/types/security/BaseOAuthClient.d.ts +25 -28
  33. package/build/types/security/Token.d.ts +0 -1
  34. package/build/types/security/Types.d.ts +39 -0
  35. package/build/types/security/authzero/AuthZeroClient.d.ts +3 -4
  36. package/build/types/security/msal/MsalClient.d.ts +14 -4
  37. package/build/types/svc/TrackService.d.ts +31 -1
  38. package/build/types/utils/js/BrowserUtils.d.ts +38 -1
  39. package/cmp/tab/TabContainerModel.ts +5 -5
  40. package/core/HoistProps.ts +1 -0
  41. package/core/XH.ts +13 -5
  42. package/core/exception/Exception.ts +19 -12
  43. package/core/types/Interfaces.ts +11 -0
  44. package/data/Store.ts +3 -0
  45. package/desktop/appcontainer/ExceptionDialog.ts +1 -1
  46. package/desktop/cmp/dash/canvas/DashCanvas.ts +2 -1
  47. package/desktop/cmp/grid/editors/BooleanEditor.ts +15 -3
  48. package/desktop/cmp/tab/TabSwitcher.ts +1 -1
  49. package/package.json +2 -2
  50. package/security/BaseOAuthClient.ts +52 -45
  51. package/security/Token.ts +0 -2
  52. package/security/Types.ts +51 -0
  53. package/security/authzero/AuthZeroClient.ts +6 -8
  54. package/security/msal/MsalClient.ts +130 -27
  55. package/svc/FetchService.ts +3 -2
  56. package/svc/TrackService.ts +94 -8
  57. package/svc/WebSocketService.ts +1 -2
  58. package/tsconfig.tsbuildinfo +1 -1
  59. package/utils/js/BrowserUtils.ts +72 -21
  60. package/utils/react/LayoutPropUtils.ts +2 -1
@@ -49,11 +49,23 @@ export const [BooleanEditor, booleanEditor] = hoistCmp.withFactory<BooleanEditor
49
49
  }
50
50
  });
51
51
 
52
- function useInstantEditor({onValueChange, initialValue, stopEditing}: CustomCellEditorProps, ref) {
52
+ function useInstantEditor(
53
+ {onValueChange, initialValue, stopEditing, eventKey, eGridCell}: CustomCellEditorProps,
54
+ ref
55
+ ) {
56
+ // Don't toggle if the user has tabbed into the editor. See https://github.com/xh/hoist-react/issues/3943.
57
+ // Fortunately, `eventKey` is null for tab, so we can use that to accept other keyboard events.
58
+ // Unfortunately, it is also null for mouse events, so we check if the grid cell is currently
59
+ // underneath the mouse position via `:hover` selector.
53
60
  useEffect(() => {
54
- onValueChange(!initialValue);
61
+ const els = document.querySelectorAll(':hover'),
62
+ topEl = els[els.length - 1];
63
+
64
+ if (eventKey || topEl === eGridCell) {
65
+ onValueChange(!initialValue);
66
+ }
55
67
  stopEditing();
56
- }, [stopEditing, initialValue, onValueChange]);
68
+ }, [stopEditing, initialValue, onValueChange, eventKey, eGridCell]);
57
69
 
58
70
  return null;
59
71
  }
@@ -41,7 +41,7 @@ import {CSSProperties, ReactElement, KeyboardEvent} from 'react';
41
41
  *
42
42
  * Overflowing tabs can be displayed in a dropdown menu if `enableOverflow` is true.
43
43
  * Note that in order for tabs to overflow, the TabSwitcher or it's wrapper must have a
44
- * a maximum width.
44
+ * maximum width.
45
45
  */
46
46
  export const [TabSwitcher, tabSwitcher] = hoistCmp.withFactory<TabSwitcherProps>({
47
47
  displayName: 'TabSwitcher',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "72.1.0",
3
+ "version": "72.3.0",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": "github:xh/hoist-react",
6
6
  "homepage": "https://xh.io",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@auth0/auth0-spa-js": "~2.1.3",
33
- "@azure/msal-browser": "~3.28.0",
33
+ "@azure/msal-browser": "~4.8.0",
34
34
  "@blueprintjs/core": "^5.10.5",
35
35
  "@blueprintjs/datetime": "^5.3.7",
36
36
  "@blueprintjs/datetime2": "^2.3.7",
@@ -9,16 +9,17 @@ import {HoistBase, managed, XH} from '@xh/hoist/core';
9
9
  import {Icon} from '@xh/hoist/icon';
10
10
  import {action, makeObservable} from '@xh/hoist/mobx';
11
11
  import {never, wait} from '@xh/hoist/promise';
12
- import {Token, TokenMap} from '@xh/hoist/security/Token';
12
+ import {Token} from '@xh/hoist/security/Token';
13
+ import {AccessTokenSpec, TokenMap} from './Types';
13
14
  import {Timer} from '@xh/hoist/utils/async';
14
15
  import {MINUTES, olderThan, ONE_MINUTE, SECONDS} from '@xh/hoist/utils/datetime';
15
16
  import {isJSON, throwIf} from '@xh/hoist/utils/js';
16
- import {find, forEach, isEmpty, isObject, keys, pickBy, union} from 'lodash';
17
+ import {find, forEach, isEmpty, isObject, keys, map, pickBy, union} from 'lodash';
17
18
  import ShortUniqueId from 'short-unique-id';
18
19
 
19
20
  export type LoginMethod = 'REDIRECT' | 'POPUP';
20
21
 
21
- export interface BaseOAuthClientConfig<S> {
22
+ export interface BaseOAuthClientConfig<S extends AccessTokenSpec> {
22
23
  /** Client ID (GUID) of your app registered with your Oauth provider. */
23
24
  clientId: string;
24
25
 
@@ -45,26 +46,17 @@ export interface BaseOAuthClientConfig<S> {
45
46
  * Governs an optional refresh timer that will work to keep the tokens fresh.
46
47
  *
47
48
  * A typical refresh will use the underlying provider cache, and should not result in
48
- * network activity. However, if any token lifetime falls below`autoRefreshSkipCacheSecs`,
49
+ * network activity. However, if any token would expire before the next autoRefresh,
49
50
  * this client will force a call to the underlying provider to get the token.
50
51
  *
51
52
  * In order to allow aging tokens to be replaced in a timely manner, this value should be
52
53
  * significantly shorter than both the minimum token lifetime that will be
53
- * returned by the underlying API and `autoRefreshSkipCacheSecs`.
54
+ * returned by the underlying API.
54
55
  *
55
56
  * Default is -1, disabling this behavior.
56
57
  */
57
58
  autoRefreshSecs?: number;
58
59
 
59
- /**
60
- * During auto-refresh, if the remaining lifetime for any token is below this threshold,
61
- * force the provider to skip the local cache and go directly to the underlying provider for
62
- * new tokens and refresh tokens.
63
- *
64
- * Default is -1, disabling this behavior.
65
- */
66
- autoRefreshSkipCacheSecs?: number;
67
-
68
60
  /**
69
61
  * Scopes to request - if any - beyond the core `['openid', 'email']` scopes, which
70
62
  * this client will always request.
@@ -72,14 +64,13 @@ export interface BaseOAuthClientConfig<S> {
72
64
  idScopes?: string[];
73
65
 
74
66
  /**
75
- * Optional map of access tokens to be loaded and maintained.
76
- *
77
- * Map of code to a spec for an access token. The code is app-determined and
78
- * will simply be used to get the loaded token via tha getAccessToken() method. The
79
- * spec is implementation specific, but will typically include scopes to be loaded
80
- * for the access token and potentially other meta-data required by the underlying provider.
67
+ * Optional spec for access tokens to be loaded and maintained to support access to one or more
68
+ * different back-end resources, distinct from the core Hoist auth flow via ID token.
81
69
  *
82
- * Use this map to gain targeted access tokens for different back-end resources.
70
+ * Map of key to a spec for an access token. The key is an arbitrary, app-determined string
71
+ * used to retrieve the loaded token via {@link getAccessTokenAsync}. The spec is implementation
72
+ * specific, but will typically include scopes to be loaded for the access token and potentially
73
+ * other metadata required by the underlying provider.
83
74
  */
84
75
  accessTokens?: Record<string, S>;
85
76
  }
@@ -89,13 +80,15 @@ export interface BaseOAuthClientConfig<S> {
89
80
  * suitable concrete implementation to power a client-side OauthService. See `MsalClient` and
90
81
  * `AuthZeroClient`
91
82
  *
92
- * Initialize such a service and this client within the `preAuthInitAsync()` lifecycle method of
93
- * `AppModel` to use the tokens it acquires to authenticate with the Hoist server. (Note this
94
- * requires a suitable server-side `AuthenticationService` implementation to validate the token and
95
- * actually resolve the user.) On init, the client implementation will initiate a pop-up or redirect
96
- * flow as necessary.
83
+ * Initialize such a service and this client within an app's primary {@link HoistAuthModel} to use
84
+ * the tokens it acquires to authenticate with the Hoist server. (Note this requires a suitable
85
+ * server-side `AuthenticationService` implementation to validate the token and actually resolve
86
+ * the user.) On init, the client impl will initiate a pop-up or redirect flow as necessary.
97
87
  */
98
- export abstract class BaseOAuthClient<C extends BaseOAuthClientConfig<S>, S> extends HoistBase {
88
+ export abstract class BaseOAuthClient<
89
+ C extends BaseOAuthClientConfig<S>,
90
+ S extends AccessTokenSpec
91
+ > extends HoistBase {
99
92
  /** Config loaded from UI server + init method. */
100
93
  protected config: C;
101
94
 
@@ -121,13 +114,12 @@ export abstract class BaseOAuthClient<C extends BaseOAuthClientConfig<S>, S> ext
121
114
  redirectUrl: 'APP_BASE_URL',
122
115
  postLogoutRedirectUrl: 'APP_BASE_URL',
123
116
  autoRefreshSecs: -1,
124
- autoRefreshSkipCacheSecs: -1,
125
117
  ...config
126
118
  };
127
119
  throwIf(!config.clientId, 'Missing OAuth clientId. Please review your configuration.');
128
120
 
129
121
  this.idScopes = union(['openid', 'email'], config.idScopes);
130
- this.accessSpecs = this.config.accessTokens;
122
+ this.accessSpecs = this.config.accessTokens ?? {};
131
123
  }
132
124
 
133
125
  /**
@@ -171,14 +163,17 @@ export abstract class BaseOAuthClient<C extends BaseOAuthClientConfig<S>, S> ext
171
163
  * Get an Access token.
172
164
  */
173
165
  async getAccessTokenAsync(key: string): Promise<Token> {
174
- return this.fetchAccessTokenAsync(this.accessSpecs[key], true);
166
+ const spec = this.accessSpecs[key];
167
+ if (!spec) throw XH.exception(`No access token spec configured for key "${key}"`);
168
+
169
+ return this.fetchAccessTokenAsync(spec, true);
175
170
  }
176
171
 
177
172
  /**
178
- * Get all available tokens.
173
+ * Get all configured tokens.
179
174
  */
180
- async getAllTokensAsync(): Promise<TokenMap> {
181
- return this.fetchAllTokensAsync(true);
175
+ async getAllTokensAsync(opts?: {eagerOnly?: boolean; useCache?: boolean}): Promise<TokenMap> {
176
+ return this.fetchAllTokensAsync(opts);
182
177
  }
183
178
 
184
179
  /**
@@ -314,12 +309,27 @@ export abstract class BaseOAuthClient<C extends BaseOAuthClientConfig<S>, S> ext
314
309
  await never();
315
310
  }
316
311
 
317
- protected async fetchAllTokensAsync(useCache = true): Promise<TokenMap> {
318
- const ret: TokenMap = {},
319
- {accessSpecs} = this;
320
- for (const key of keys(accessSpecs)) {
321
- ret[key] = await this.fetchAccessTokenAsync(accessSpecs[key], useCache);
322
- }
312
+ protected async fetchAllTokensAsync(opts?: {
313
+ eagerOnly?: boolean;
314
+ useCache?: boolean;
315
+ }): Promise<TokenMap> {
316
+ const eagerOnly = opts?.eagerOnly ?? false,
317
+ useCache = opts?.useCache ?? true,
318
+ accessSpecs = eagerOnly
319
+ ? pickBy(this.accessSpecs, spec => spec.fetchMode !== 'lazy') // specs are eager by default - opt-in to lazy
320
+ : this.accessSpecs,
321
+ ret: TokenMap = {};
322
+
323
+ await Promise.allSettled(
324
+ map(accessSpecs, async (spec, key) => {
325
+ try {
326
+ ret[key] = await this.fetchAccessTokenAsync(spec, useCache);
327
+ } catch (e) {
328
+ XH.handleException(e, {logOnServer: true, showAlert: false});
329
+ }
330
+ })
331
+ );
332
+
323
333
  // Do this after getting any access tokens --which can also populate the idToken cache!
324
334
  ret.id = await this.fetchIdTokenSafeAsync(useCache);
325
335
 
@@ -361,22 +371,19 @@ export abstract class BaseOAuthClient<C extends BaseOAuthClientConfig<S>, S> ext
361
371
  private async onTimerAsync(): Promise<void> {
362
372
  const {config, lastRefreshAttempt} = this,
363
373
  refreshSecs = config.autoRefreshSecs * SECONDS,
364
- skipCacheSecs = config.autoRefreshSkipCacheSecs * SECONDS;
374
+ skipCacheSecs = refreshSecs + 5 * SECONDS;
365
375
 
366
376
  if (olderThan(lastRefreshAttempt, refreshSecs)) {
367
377
  this.lastRefreshAttempt = Date.now();
368
378
  try {
369
379
  this.logDebug('Refreshing all tokens:');
370
380
  let tokens = await this.fetchAllTokensAsync(),
371
- aging = pickBy(
372
- tokens,
373
- v => skipCacheSecs > 0 && v.expiresWithin(skipCacheSecs)
374
- );
381
+ aging = pickBy(tokens, v => v.expiresWithin(skipCacheSecs));
375
382
  if (!isEmpty(aging)) {
376
383
  this.logDebug(
377
384
  `Tokens [${keys(aging).join(', ')}] have < ${skipCacheSecs}s remaining, reloading without cache.`
378
385
  );
379
- tokens = await this.fetchAllTokensAsync(false);
386
+ tokens = await this.fetchAllTokensAsync({useCache: false});
380
387
  }
381
388
  this.logTokensDebug(tokens);
382
389
  } catch (e) {
package/security/Token.ts CHANGED
@@ -11,8 +11,6 @@ import {jwtDecode} from 'jwt-decode';
11
11
  import {getRelativeTimestamp} from '@xh/hoist/cmp/relativetimestamp';
12
12
  import {isNil} from 'lodash';
13
13
 
14
- export type TokenMap = Record<string, Token>;
15
-
16
14
  export class Token {
17
15
  readonly value: string;
18
16
  readonly decoded: PlainObject;
@@ -0,0 +1,51 @@
1
+ /*
2
+ * This file belongs to Hoist, an application development toolkit
3
+ * developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
4
+ *
5
+ * Copyright © 2025 Extremely Heavy Industries Inc.
6
+ */
7
+
8
+ import {Token} from './Token';
9
+
10
+ export interface AccessTokenSpec {
11
+ /**
12
+ * Mode governing when the access token should be requested from provider:
13
+ * - eager (or undefined) - load on overall initialization, but do not block on failure.
14
+ * Useful for tokens that an app is almost certain to require during a user session.
15
+ * - lazy - defer loading until first requested by client. Useful for tokens that might
16
+ * never be needed by the app during a given user session.
17
+ */
18
+ fetchMode?: 'eager' | 'lazy';
19
+
20
+ /** Scopes for the desired access token.*/
21
+ scopes: string[];
22
+ }
23
+
24
+ export type TokenMap = Record<string, Token>;
25
+
26
+ /** Aggregated telemetry results, produced by {@link MsalClient} when enabled via config. */
27
+ export interface TelemetryResults {
28
+ /** Stats by event type - */
29
+ events: Record<string, TelemetryEventResults>;
30
+ }
31
+
32
+ /** Aggregated telemetry results for a single type of event. */
33
+ export interface TelemetryEventResults {
34
+ firstTime: Date;
35
+ lastTime: Date;
36
+ successCount: number;
37
+ failureCount: number;
38
+ /** Timing info (in ms) for event instances reported with duration. */
39
+ duration: {
40
+ count: number;
41
+ total: number;
42
+ average: number;
43
+ worst: number;
44
+ };
45
+ lastFailure?: {
46
+ time: Date;
47
+ duration: number;
48
+ code: string;
49
+ name: string;
50
+ };
51
+ }
@@ -8,7 +8,8 @@ import type {Auth0ClientOptions} from '@auth0/auth0-spa-js';
8
8
  import {Auth0Client} from '@auth0/auth0-spa-js';
9
9
  import {XH} from '@xh/hoist/core';
10
10
  import {wait} from '@xh/hoist/promise';
11
- import {Token, TokenMap} from '@xh/hoist/security/Token';
11
+ import {Token} from '@xh/hoist/security/Token';
12
+ import {AccessTokenSpec, TokenMap} from '../Types';
12
13
  import {SECONDS} from '@xh/hoist/utils/datetime';
13
14
  import {mergeDeep, throwIf} from '@xh/hoist/utils/js';
14
15
  import {flatMap, union} from 'lodash';
@@ -40,10 +41,7 @@ export interface AuthZeroClientConfig extends BaseOAuthClientConfig<AuthZeroToke
40
41
  authZeroClientOptions?: Partial<Auth0ClientOptions>;
41
42
  }
42
43
 
43
- export interface AuthZeroTokenSpec {
44
- /** Scopes for the desired access token.*/
45
- scopes: string[];
46
-
44
+ export interface AuthZeroTokenSpec extends AccessTokenSpec {
47
45
  /**
48
46
  * Audience (i.e. API) identifier for AccessToken. Must be registered with Auth0.
49
47
  * Note that this is required to ensure that issued token is a JWT and not an opaque string.
@@ -75,7 +73,7 @@ export class AuthZeroClient extends BaseOAuthClient<AuthZeroClientConfig, AuthZe
75
73
  const {appState} = await client.handleRedirectCallback();
76
74
  this.restoreRedirectState(appState);
77
75
  await this.noteUserAuthenticatedAsync();
78
- return this.fetchAllTokensAsync();
76
+ return this.fetchAllTokensAsync({eagerOnly: true});
79
77
  }
80
78
 
81
79
  // 1) If we are logged in, try to just reload tokens silently. This is the happy path on
@@ -83,7 +81,7 @@ export class AuthZeroClient extends BaseOAuthClient<AuthZeroClientConfig, AuthZe
83
81
  if (await client.isAuthenticated()) {
84
82
  try {
85
83
  this.logDebug('Attempting silent token load.');
86
- return await this.fetchAllTokensAsync();
84
+ return await this.fetchAllTokensAsync({eagerOnly: true});
87
85
  } catch (e) {
88
86
  this.logDebug('Failed to load tokens on init, fall back to login', e.message ?? e);
89
87
  }
@@ -94,7 +92,7 @@ export class AuthZeroClient extends BaseOAuthClient<AuthZeroClientConfig, AuthZe
94
92
  await this.loginAsync();
95
93
 
96
94
  // 3) return tokens
97
- return this.fetchAllTokensAsync();
95
+ return this.fetchAllTokensAsync({eagerOnly: true});
98
96
  }
99
97
 
100
98
  protected override async doLoginRedirectAsync(): Promise<void> {
@@ -7,16 +7,19 @@
7
7
  import * as msal from '@azure/msal-browser';
8
8
  import {
9
9
  AccountInfo,
10
+ BrowserPerformanceClient,
11
+ Configuration,
10
12
  IPublicClientApplication,
11
13
  LogLevel,
12
14
  PopupRequest,
13
15
  SilentRequest
14
16
  } from '@azure/msal-browser';
15
17
  import {XH} from '@xh/hoist/core';
16
- import {Token, TokenMap} from '@xh/hoist/security/Token';
18
+ import {Token} from '@xh/hoist/security/Token';
17
19
  import {logDebug, logError, logInfo, logWarn, mergeDeep, throwIf} from '@xh/hoist/utils/js';
18
20
  import {flatMap, union, uniq} from 'lodash';
19
21
  import {BaseOAuthClient, BaseOAuthClientConfig} from '../BaseOAuthClient';
22
+ import {AccessTokenSpec, TelemetryResults, TokenMap} from '../Types';
20
23
 
21
24
  export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
22
25
  /**
@@ -33,6 +36,13 @@ export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
33
36
  */
34
37
  domainHint?: string;
35
38
 
39
+ /**
40
+ * True to enable support for built-in telemetry provided by this class's internal MSAL client.
41
+ * Captured performance events will be summarized via {@link telemetryResults}.
42
+ * See https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/performance.md
43
+ */
44
+ enableTelemetry?: boolean;
45
+
36
46
  /**
37
47
  * If specified, the client will use this value when initializing the app to enforce a minimum
38
48
  * amount of time during which no further auth flow with the provider should be necessary.
@@ -65,10 +75,7 @@ export interface MsalClientConfig extends BaseOAuthClientConfig<MsalTokenSpec> {
65
75
  msalClientOptions?: Partial<msal.Configuration>;
66
76
  }
67
77
 
68
- export interface MsalTokenSpec {
69
- /** Scopes for the desired access token. */
70
- scopes: string[];
71
-
78
+ export interface MsalTokenSpec extends AccessTokenSpec {
72
79
  /**
73
80
  * Scopes to be added to the scopes requested during interactive and SSO logins.
74
81
  * See the `scopes` property on `PopupRequest`, `RedirectRequest`, and `SSORequest`
@@ -106,6 +113,10 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
106
113
  private account: AccountInfo; // Authenticated account
107
114
  private initialTokenLoad: boolean;
108
115
 
116
+ /** Enable telemetry via `enableTelemetry` ctor config, or via {@link enableTelemetry}. */
117
+ telemetryResults: TelemetryResults = {events: {}};
118
+ private _telemetryCbHandle: string = null;
119
+
109
120
  constructor(config: MsalClientConfig) {
110
121
  super({
111
122
  initRefreshTokenExpirationOffsetSecs: -1,
@@ -120,6 +131,9 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
120
131
  //-------------------------------------------
121
132
  protected override async doInitAsync(): Promise<TokenMap> {
122
133
  const client = (this.client = await this.createClientAsync());
134
+ if (this.config.enableTelemetry) {
135
+ this.enableTelemetry();
136
+ }
123
137
 
124
138
  // 0) Handle redirect return
125
139
  const redirectResp = await client.handleRedirectPromise();
@@ -127,7 +141,7 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
127
141
  this.logDebug('Completing Redirect login');
128
142
  this.noteUserAuthenticated(redirectResp.account);
129
143
  this.restoreRedirectState(redirectResp.state);
130
- return this.fetchAllTokensAsync();
144
+ return this.fetchAllTokensAsync({eagerOnly: true});
131
145
  }
132
146
 
133
147
  // 1) If we are logged in, try to just reload tokens silently. This is the happy path on
@@ -142,7 +156,7 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
142
156
  try {
143
157
  this.initialTokenLoad = true;
144
158
  this.logDebug('Attempting silent token load.');
145
- return await this.fetchAllTokensAsync();
159
+ return await this.fetchAllTokensAsync({eagerOnly: true});
146
160
  } catch (e) {
147
161
  this.account = null;
148
162
  this.logDebug('Failed to load tokens on init, fall back to login', e.message ?? e);
@@ -171,7 +185,7 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
171
185
  }
172
186
 
173
187
  // 3) Return tokens
174
- return this.fetchAllTokensAsync();
188
+ return this.fetchAllTokensAsync({eagerOnly: true});
175
189
  }
176
190
 
177
191
  protected override async doLoginPopupAsync(): Promise<void> {
@@ -249,6 +263,86 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
249
263
  : await client.logoutPopup(opts);
250
264
  }
251
265
 
266
+ //------------------------
267
+ // Telemetry
268
+ //------------------------
269
+ enableTelemetry(): void {
270
+ if (this._telemetryCbHandle) {
271
+ this.logInfo('Telemetry already enabled', this.telemetryResults);
272
+ return;
273
+ }
274
+
275
+ this.telemetryResults = {events: {}};
276
+
277
+ this._telemetryCbHandle = this.client.addPerformanceCallback(events => {
278
+ events.forEach(e => {
279
+ try {
280
+ const {events} = this.telemetryResults,
281
+ {name, startTimeMs, durationMs, success, errorName, errorCode} = e,
282
+ eTime = startTimeMs ? new Date(startTimeMs) : new Date();
283
+
284
+ const eResult = (events[name] ??= {
285
+ firstTime: eTime,
286
+ lastTime: eTime,
287
+ successCount: 0,
288
+ failureCount: 0,
289
+ duration: {count: 0, total: 0, average: 0, worst: 0},
290
+ lastFailure: null
291
+ });
292
+ eResult.lastTime = eTime;
293
+
294
+ if (success) {
295
+ eResult.successCount++;
296
+ } else {
297
+ eResult.failureCount++;
298
+ eResult.lastFailure = {
299
+ time: eTime,
300
+ duration: e.durationMs,
301
+ code: errorCode,
302
+ name: errorName
303
+ };
304
+ }
305
+
306
+ if (durationMs) {
307
+ const {duration} = eResult;
308
+ duration.count++;
309
+ duration.total += durationMs;
310
+ duration.average = Math.round(duration.total / duration.count);
311
+ duration.worst = Math.max(duration.worst, durationMs);
312
+ }
313
+ } catch (e) {
314
+ this.logError(`Error processing telemetry event`, e);
315
+ }
316
+ });
317
+ });
318
+
319
+ // Ask TrackService to include in background health check report, if enabled on that service.
320
+ // Handle TrackService not yet initialized (common, this client likely initialized before.)
321
+ this.addReaction({
322
+ when: () => XH.appIsRunning,
323
+ run: () =>
324
+ XH.trackService.addClientHealthReportSource(
325
+ 'msalClient',
326
+ () => this.telemetryResults
327
+ )
328
+ });
329
+
330
+ this.logInfo('Telemetry enabled');
331
+ }
332
+
333
+ disableTelemetry(): void {
334
+ if (!this._telemetryCbHandle) {
335
+ this.logInfo('Telemetry already disabled');
336
+ return;
337
+ }
338
+
339
+ this.client.removePerformanceCallback(this._telemetryCbHandle);
340
+ this._telemetryCbHandle = null;
341
+
342
+ XH.trackService.removeClientHealthReportSource('msalClient');
343
+ this.logInfo('Telemetry disabled', this.telemetryResults);
344
+ }
345
+
252
346
  //------------------------
253
347
  // Private implementation
254
348
  //------------------------
@@ -265,29 +359,38 @@ export class MsalClient extends BaseOAuthClient<MsalClientConfig, MsalTokenSpec>
265
359
  }
266
360
 
267
361
  private async createClientAsync(): Promise<IPublicClientApplication> {
268
- const {clientId, authority, msalLogLevel, msalClientOptions} = this.config;
362
+ const {clientId, authority, msalLogLevel, msalClientOptions, enableTelemetry} = this.config;
269
363
  throwIf(!authority, 'Missing MSAL authority. Please review your configuration.');
270
364
 
271
- return msal.PublicClientApplication.createPublicClientApplication(
272
- mergeDeep(
273
- {
274
- auth: {
275
- clientId,
276
- authority,
277
- postLogoutRedirectUri: this.postLogoutRedirectUrl
278
- },
279
- system: {
280
- loggerOptions: {
281
- loggerCallback: this.logFromMsal,
282
- logLevel: msalLogLevel
283
- }
284
- },
285
- cache: {
286
- cacheLocation: 'localStorage' // allows sharing auth info across tabs.
365
+ const mergedConf: Configuration = mergeDeep(
366
+ {
367
+ auth: {
368
+ clientId,
369
+ authority,
370
+ postLogoutRedirectUri: this.postLogoutRedirectUrl
371
+ },
372
+ system: {
373
+ loggerOptions: {
374
+ loggerCallback: this.logFromMsal,
375
+ logLevel: msalLogLevel
287
376
  }
288
377
  },
289
- msalClientOptions
290
- )
378
+ cache: {
379
+ cacheLocation: 'localStorage' // allows sharing auth info across tabs.
380
+ }
381
+ },
382
+ msalClientOptions
383
+ );
384
+
385
+ return msal.PublicClientApplication.createPublicClientApplication(
386
+ enableTelemetry
387
+ ? {
388
+ ...mergedConf,
389
+ telemetry: {
390
+ client: new BrowserPerformanceClient(mergedConf)
391
+ }
392
+ }
393
+ : mergedConf
291
394
  );
292
395
  }
293
396
 
@@ -217,8 +217,9 @@ export class FetchService extends HoistService {
217
217
  //-----------------------
218
218
  private async fetchInternalAsync(opts: FetchOptions): Promise<any> {
219
219
  opts = this.withCorrelationId(opts);
220
- opts = await this.withDefaultHeadersAsync(opts);
221
- let ret = this.managedFetchAsync(opts);
220
+
221
+ // Core Promise - chained with custom headers callback to ensure that work is included in overall tracked time.
222
+ let ret = this.withDefaultHeadersAsync(opts).then(opts => this.managedFetchAsync(opts));
222
223
 
223
224
  // Apply tracking
224
225
  const {correlationId, loadSpec, track} = opts;