@xh/hoist 73.0.0-SNAPSHOT.1744325910318 → 73.0.0-SNAPSHOT.1744391471093

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.
@@ -4,8 +4,8 @@
4
4
  *
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
- import {HoistService, XH} from '@xh/hoist/core';
8
- import {Icon} from '@xh/hoist/icon';
7
+ import {HoistService, PlainObject, XH} from '@xh/hoist/core';
8
+ import {withFormattedTimestamps} from '@xh/hoist/format';
9
9
  import {action, makeObservable, observable} from '@xh/hoist/mobx';
10
10
  import {Timer} from '@xh/hoist/utils/async';
11
11
  import {SECONDS} from '@xh/hoist/utils/datetime';
@@ -43,13 +43,14 @@ export class WebSocketService extends HoistService {
43
43
  readonly FORCE_APP_SUSPEND_TOPIC = 'xhForceAppSuspend';
44
44
  readonly REQ_CLIENT_HEALTH_RPT_TOPIC = 'xhRequestClientHealthReport';
45
45
 
46
+ /** True if WebSockets generally enabled - set statically in code via {@link AppSpec}. */
47
+ enabled: boolean = XH.appSpec.webSocketsEnabled;
48
+
46
49
  /** Unique channel assigned by server upon successful connection. */
47
- @observable
48
- channelKey: string = null;
50
+ @observable channelKey: string = null;
49
51
 
50
52
  /** Last time a message was received, including heartbeat messages. */
51
- @observable
52
- lastMessageTime: Date = null;
53
+ @observable lastMessageTime: Date = null;
53
54
 
54
55
  /** Observable flag indicating service is connected and available for use. */
55
56
  get connected(): boolean {
@@ -59,11 +60,11 @@ export class WebSocketService extends HoistService {
59
60
  /** Set to true to log all sent/received messages - very chatty. */
60
61
  logMessages: boolean = false;
61
62
 
63
+ telemetry: WebSocketTelemetry = null;
64
+
62
65
  private _timer: Timer;
63
66
  private _socket: WebSocket;
64
- private _subsByTopic = {};
65
-
66
- enabled: boolean = XH.appSpec.webSocketsEnabled;
67
+ private _subsByTopic: Record<string, WebSocketSubscription[]> = {};
67
68
 
68
69
  constructor() {
69
70
  super();
@@ -72,6 +73,7 @@ export class WebSocketService extends HoistService {
72
73
 
73
74
  override async initAsync() {
74
75
  if (!this.enabled) return;
76
+
75
77
  const {environmentService} = XH;
76
78
  if (environmentService.get('webSocketsEnabled') === false) {
77
79
  this.logError(
@@ -80,6 +82,7 @@ export class WebSocketService extends HoistService {
80
82
  this.enabled = false;
81
83
  return;
82
84
  }
85
+ this.telemetry = {channelKey: null, subscriptionCount: 0, events: {}};
83
86
 
84
87
  this.connect();
85
88
 
@@ -112,6 +115,7 @@ export class WebSocketService extends HoistService {
112
115
 
113
116
  const newSub = new WebSocketSubscription(topic, fn);
114
117
  subs.push(newSub);
118
+ this.telemetry.subscriptionCount++;
115
119
  return newSub;
116
120
  }
117
121
 
@@ -123,6 +127,7 @@ export class WebSocketService extends HoistService {
123
127
  unsubscribe(subscription: WebSocketSubscription) {
124
128
  const subs = this.getSubsForTopic(subscription.topic);
125
129
  pull(subs, subscription);
130
+ this.telemetry.subscriptionCount--;
126
131
  }
127
132
 
128
133
  /**
@@ -133,13 +138,24 @@ export class WebSocketService extends HoistService {
133
138
  throwIf(!this.connected, 'Unable to send message via websocket - not connected.');
134
139
 
135
140
  this._socket.send(JSON.stringify(message));
141
+
142
+ this.noteTelemetryEvent('msgSent');
136
143
  this.maybeLogMessage('Sent message', message);
137
144
  }
138
145
 
146
+ shutdown() {
147
+ if (this._timer) this._timer.cancel();
148
+ this.disconnect();
149
+ }
150
+
151
+ getFormattedTelemetry(): PlainObject {
152
+ return withFormattedTimestamps(this.telemetry);
153
+ }
154
+
139
155
  //------------------------
140
156
  // Implementation
141
157
  //------------------------
142
- connect() {
158
+ private connect() {
143
159
  try {
144
160
  // Create new socket and wire up events. Be sure to ignore obsolete sockets
145
161
  const s = new WebSocket(this.buildWebSocketUrl());
@@ -163,7 +179,7 @@ export class WebSocketService extends HoistService {
163
179
  this.updateConnectedStatus();
164
180
  }
165
181
 
166
- disconnect() {
182
+ private disconnect() {
167
183
  if (this._socket) {
168
184
  this._socket.close();
169
185
  this._socket = null;
@@ -171,12 +187,13 @@ export class WebSocketService extends HoistService {
171
187
  this.updateConnectedStatus();
172
188
  }
173
189
 
174
- heartbeatOrReconnect() {
190
+ private heartbeatOrReconnect() {
175
191
  this.updateConnectedStatus();
176
192
  if (this.connected) {
177
193
  this.sendMessage({topic: this.HEARTBEAT_TOPIC, data: 'ping'});
178
194
  } else {
179
195
  this.logWarn('Heartbeat found websocket not connected - attempting to reconnect...');
196
+ this.noteTelemetryEvent('heartbeatReconnectAttempt');
180
197
  this.disconnect();
181
198
  this.connect();
182
199
  }
@@ -184,30 +201,29 @@ export class WebSocketService extends HoistService {
184
201
 
185
202
  private onServerInstanceChange() {
186
203
  this.logWarn('Server instance changed - attempting to connect to new instance.');
204
+ this.noteTelemetryEvent('instanceChangeReconnectAttempt');
187
205
  this.disconnect();
188
206
  this.connect();
189
207
  }
190
208
 
191
- shutdown() {
192
- if (this._timer) this._timer.cancel();
193
- this.disconnect();
194
- }
195
-
196
209
  //------------------------
197
210
  // Socket events impl
198
211
  //------------------------
199
212
  onOpen(ev) {
200
213
  this.logDebug('WebSocket connection opened', ev);
214
+ this.noteTelemetryEvent('connOpened');
201
215
  this.updateConnectedStatus();
202
216
  }
203
217
 
204
218
  onClose(ev) {
205
219
  this.logDebug('WebSocket connection closed', ev);
220
+ this.noteTelemetryEvent('connClosed');
206
221
  this.updateConnectedStatus();
207
222
  }
208
223
 
209
224
  onError(ev) {
210
225
  this.logError('WebSocket connection error', ev);
226
+ this.noteTelemetryEvent('connError');
211
227
  this.updateConnectedStatus();
212
228
  }
213
229
 
@@ -219,6 +235,7 @@ export class WebSocketService extends HoistService {
219
235
  // Record arrival
220
236
  this.updateLastMessageTime();
221
237
  this.maybeLogMessage('Received message', rawMsg);
238
+ this.noteTelemetryEvent('msgReceived');
222
239
 
223
240
  // Hoist and app handling
224
241
  switch (topic) {
@@ -244,7 +261,7 @@ export class WebSocketService extends HoistService {
244
261
  //------------------------
245
262
  // Subscription impl
246
263
  //------------------------
247
- notifySubscribers(message) {
264
+ private notifySubscribers(message) {
248
265
  const subs = this.getSubsForTopic(message.topic);
249
266
 
250
267
  subs.forEach(sub => {
@@ -256,7 +273,7 @@ export class WebSocketService extends HoistService {
256
273
  });
257
274
  }
258
275
 
259
- getSubsForTopic(topic): WebSocketSubscription[] {
276
+ private getSubsForTopic(topic: string): WebSocketSubscription[] {
260
277
  let ret = this._subsByTopic[topic];
261
278
  if (!ret) {
262
279
  ret = this._subsByTopic[topic] = [];
@@ -267,7 +284,7 @@ export class WebSocketService extends HoistService {
267
284
  //------------------------
268
285
  // Other impl
269
286
  //------------------------
270
- updateConnectedStatus() {
287
+ private updateConnectedStatus() {
271
288
  const socketOpen = this._socket?.readyState === WebSocket.OPEN;
272
289
  if (!socketOpen && this.channelKey) {
273
290
  this.installChannelKey(null);
@@ -275,16 +292,17 @@ export class WebSocketService extends HoistService {
275
292
  }
276
293
 
277
294
  @action
278
- installChannelKey(key) {
295
+ private installChannelKey(key: string) {
279
296
  this.channelKey = key;
297
+ this.telemetry.channelKey = key;
280
298
  }
281
299
 
282
300
  @action
283
- updateLastMessageTime() {
301
+ private updateLastMessageTime() {
284
302
  this.lastMessageTime = new Date();
285
303
  }
286
304
 
287
- buildWebSocketUrl() {
305
+ private buildWebSocketUrl() {
288
306
  const protocol = window.location.protocol == 'https:' ? 'wss:' : 'ws:',
289
307
  endpoint = 'xhWebSocket?clientAppVersion=' + XH.appVersion;
290
308
  return XH.isDevelopmentMode
@@ -292,16 +310,14 @@ export class WebSocketService extends HoistService {
292
310
  : `${protocol}//${window.location.host}${XH.baseUrl}${endpoint}`;
293
311
  }
294
312
 
295
- showTestMessageAlert(message) {
296
- XH.alert({
297
- title: 'Test Message',
298
- icon: Icon.bullhorn(),
299
- message
300
- });
313
+ private maybeLogMessage(...args) {
314
+ if (this.logMessages) this.logDebug(args);
301
315
  }
302
316
 
303
- maybeLogMessage(...args) {
304
- if (this.logMessages) this.logDebug(args);
317
+ private noteTelemetryEvent(eventKey: keyof WebSocketTelemetry['events']) {
318
+ const evtTel = (this.telemetry.events[eventKey] ??= {count: 0, lastTime: null});
319
+ evtTel.count++;
320
+ evtTel.lastTime = Date.now();
305
321
  }
306
322
  }
307
323
 
@@ -327,3 +343,23 @@ export interface WebSocketMessage {
327
343
  topic: string;
328
344
  data?: any;
329
345
  }
346
+
347
+ /** Telemetry collected by this service + included in {@link ClientHealthService} reporting. */
348
+ export interface WebSocketTelemetry {
349
+ channelKey: string;
350
+ subscriptionCount: number;
351
+ events: {
352
+ connOpened?: WebSocketEventTelemetry;
353
+ connClosed?: WebSocketEventTelemetry;
354
+ connError?: WebSocketEventTelemetry;
355
+ msgReceived?: WebSocketEventTelemetry;
356
+ msgSent?: WebSocketEventTelemetry;
357
+ heartbeatReconnectAttempt?: WebSocketEventTelemetry;
358
+ instanceChangeReconnectAttempt?: WebSocketEventTelemetry;
359
+ };
360
+ }
361
+
362
+ export interface WebSocketEventTelemetry {
363
+ count: number;
364
+ lastTime: number;
365
+ }