@xh/hoist 73.0.0-SNAPSHOT.1746826299049 → 73.0.0-SNAPSHOT.1746826954718
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/CHANGELOG.md +4 -4
- package/build/types/svc/WebSocketService.d.ts +10 -1
- package/core/HoistBaseDecorators.ts +29 -31
- package/mobx/decorators.ts +32 -4
- package/mobx/overrides.ts +4 -17
- package/package.json +1 -1
- package/svc/WebSocketService.ts +37 -4
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -36,9 +36,6 @@
|
|
|
36
36
|
* Fixed drag-and-drop usability issues with the mobile `ColChooser`.
|
|
37
37
|
* Made `GridModel.defaultGroupSortFn` null-safe and improved type signature.
|
|
38
38
|
* Disable `dashCanvasAddViewButton` if there are no `menuItems` to show.
|
|
39
|
-
* Improvements to `@bindable` and `@persist` to handle lifecycle-related bugs. Note that previously
|
|
40
|
-
`@bindable` would work even if `makeObservable()` was not called, but this is no longer the case.
|
|
41
|
-
Please ensure that `makeObservable()` is called in your model's constructor when using `@bindable`.
|
|
42
39
|
|
|
43
40
|
### ⚙️ Typescript API Adjustments
|
|
44
41
|
|
|
@@ -58,7 +55,10 @@
|
|
|
58
55
|
* ⚠️ NOTE that a misconfigured build - where the client version is not set to the same value
|
|
59
56
|
as the server - would result in a false positive for an upgrade. The two should always match.
|
|
60
57
|
* Calls to `Promise.track()` that are rejected with an exception will be tracked with new
|
|
61
|
-
severity level of `TrackSeverity.ERROR
|
|
58
|
+
severity level of `TrackSeverity.ERROR`.
|
|
59
|
+
* Improved client `WebSocketService` heartbeat to check that it has been receiving inbound messages
|
|
60
|
+
from the server, not just successfully sending outbound heartbeats.
|
|
61
|
+
* Improved client `WebSocketService` to throttle its reconnect attempts.
|
|
62
62
|
|
|
63
63
|
## v72.5.1 - 2025-04-15
|
|
64
64
|
|
|
@@ -25,6 +25,12 @@ import { HoistService, PlainObject } from '@xh/hoist/core';
|
|
|
25
25
|
export declare class WebSocketService extends HoistService {
|
|
26
26
|
static instance: WebSocketService;
|
|
27
27
|
readonly HEARTBEAT_TOPIC = "xhHeartbeat";
|
|
28
|
+
/** Check connection and send a new heartbeat (which should be promptly ack'd) every 10s. */
|
|
29
|
+
readonly HEARTBEAT_INTERVAL: number;
|
|
30
|
+
/** If no heartbeat ack (or other msg) received for past 30s, assume we are disconnected. */
|
|
31
|
+
readonly HEARTBEAT_ACK_TIMEOUT: number;
|
|
32
|
+
/** Wait a well-defined interval before trying to reconnect again. */
|
|
33
|
+
readonly HEARTBEAT_RECONNECT_INTERVAL: number;
|
|
28
34
|
readonly REG_SUCCESS_TOPIC = "xhRegistrationSuccess";
|
|
29
35
|
readonly FORCE_APP_SUSPEND_TOPIC = "xhForceAppSuspend";
|
|
30
36
|
readonly REQ_CLIENT_HEALTH_RPT_TOPIC = "xhRequestClientHealthReport";
|
|
@@ -43,6 +49,7 @@ export declare class WebSocketService extends HoistService {
|
|
|
43
49
|
private _timer;
|
|
44
50
|
private _socket;
|
|
45
51
|
private _subsByTopic;
|
|
52
|
+
private _lastHeartbeatReconnectAttempt;
|
|
46
53
|
constructor();
|
|
47
54
|
initAsync(): Promise<void>;
|
|
48
55
|
/**
|
|
@@ -57,7 +64,6 @@ export declare class WebSocketService extends HoistService {
|
|
|
57
64
|
subscribe(topic: string, fn: (msg: WebSocketMessage) => any): WebSocketSubscription;
|
|
58
65
|
/**
|
|
59
66
|
* Cancel a subscription for a given topic/handler.
|
|
60
|
-
*
|
|
61
67
|
* @param subscription - WebSocketSubscription returned when the subscription was established.
|
|
62
68
|
*/
|
|
63
69
|
unsubscribe(subscription: WebSocketSubscription): void;
|
|
@@ -109,6 +115,9 @@ export interface WebSocketTelemetry {
|
|
|
109
115
|
connError?: WebSocketEventTelemetry;
|
|
110
116
|
msgReceived?: WebSocketEventTelemetry;
|
|
111
117
|
msgSent?: WebSocketEventTelemetry;
|
|
118
|
+
heartbeatReceived?: WebSocketEventTelemetry;
|
|
119
|
+
heartbeatSent?: WebSocketEventTelemetry;
|
|
120
|
+
heartbeatFailed?: WebSocketEventTelemetry;
|
|
112
121
|
heartbeatReconnectAttempt?: WebSocketEventTelemetry;
|
|
113
122
|
instanceChangeReconnectAttempt?: WebSocketEventTelemetry;
|
|
114
123
|
};
|
|
@@ -4,8 +4,6 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Copyright © 2025 Extremely Heavy Industries Inc.
|
|
6
6
|
*/
|
|
7
|
-
import {wait} from '@xh/hoist/promise';
|
|
8
|
-
import {observable} from 'mobx';
|
|
9
7
|
import {logError, throwIf} from '../utils/js';
|
|
10
8
|
import {HoistBaseClass, PersistableState, PersistenceProvider, PersistOptions} from './';
|
|
11
9
|
|
|
@@ -73,38 +71,38 @@ function createPersistDescriptor(
|
|
|
73
71
|
);
|
|
74
72
|
return descriptor;
|
|
75
73
|
}
|
|
76
|
-
const codeValue = descriptor.initializer
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
const codeValue = descriptor.initializer;
|
|
75
|
+
let hasInitialized = false,
|
|
76
|
+
ret;
|
|
77
|
+
const initializer = function () {
|
|
78
|
+
// Initializer can be called multiple times when stacking decorators.
|
|
79
|
+
if (hasInitialized) return ret;
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
81
|
+
// codeValue undefined if no initial in-code value provided, otherwise call to get initial value.
|
|
82
|
+
ret = codeValue?.call(this);
|
|
83
|
+
|
|
84
|
+
const persistOptions = {
|
|
85
|
+
path: property,
|
|
86
|
+
...PersistenceProvider.mergePersistOptions(this.persistWith, options)
|
|
87
|
+
};
|
|
88
|
+
PersistenceProvider.create({
|
|
89
|
+
persistOptions,
|
|
90
|
+
owner: this,
|
|
91
|
+
target: {
|
|
92
|
+
getPersistableState: () =>
|
|
93
|
+
new PersistableState(hasInitialized ? this[property] : ret),
|
|
94
|
+
setPersistableState: state => {
|
|
95
|
+
if (!hasInitialized) {
|
|
96
|
+
ret = state.value;
|
|
97
|
+
} else {
|
|
98
|
+
this[property] = state.value;
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Wait for next tick to ensure construction has completed and property has been made
|
|
104
|
-
// observable via makeObservable.
|
|
105
|
-
wait().thenAction(() => propertyAvailable.set(true));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
106
103
|
|
|
107
|
-
|
|
108
|
-
|
|
104
|
+
hasInitialized = true;
|
|
105
|
+
return ret;
|
|
106
|
+
};
|
|
109
107
|
return {...descriptor, initializer};
|
|
110
108
|
}
|
package/mobx/decorators.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* Copyright © 2025 Extremely Heavy Industries Inc.
|
|
6
6
|
*/
|
|
7
7
|
import {upperFirst} from 'lodash';
|
|
8
|
+
import {observable, runInAction} from 'mobx';
|
|
9
|
+
import {getOrCreate} from '../utils/js';
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Decorator to mark a property as observable and also provide a simple MobX action of the
|
|
@@ -38,14 +40,40 @@ function createBindable(target, name, descriptor, isRef) {
|
|
|
38
40
|
Object.defineProperty(target, setterName, {value});
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
// 2)
|
|
42
|
-
|
|
43
|
+
// 2) Place a hidden getter on prototype that wraps the backing observable.
|
|
44
|
+
const {initializer} = descriptor,
|
|
45
|
+
propName = `_${name}_bindable`,
|
|
46
|
+
valName = `_${name}_bindable_value`;
|
|
47
|
+
Object.defineProperty(target, propName, {
|
|
48
|
+
get() {
|
|
49
|
+
return getOrCreate(this, valName, () => {
|
|
50
|
+
const initVal = initializer?.call(this);
|
|
51
|
+
return isRef ? observable.box(initVal, {deep: false}) : observable.box(initVal);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// 3) Create the descriptor for a getter/setter pair..
|
|
57
|
+
descriptor = {
|
|
58
|
+
get() {
|
|
59
|
+
return this[propName].get();
|
|
60
|
+
},
|
|
61
|
+
set(v) {
|
|
62
|
+
runInAction(() => this[propName].set(v));
|
|
63
|
+
},
|
|
64
|
+
enumerable: true,
|
|
65
|
+
configurable: true
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// 4) Record on class, so we can later create on *instance* in makeObservable.
|
|
69
|
+
// (Be sure to create cloned list for *this* particular class.)
|
|
43
70
|
const key = '_xhBindableProperties';
|
|
44
71
|
if (!target.hasOwnProperty(key)) {
|
|
45
72
|
target[key] = {...target[key]};
|
|
46
73
|
}
|
|
47
|
-
target[key][name] =
|
|
74
|
+
target[key][name] = descriptor;
|
|
48
75
|
|
|
49
|
-
//
|
|
76
|
+
// 5) Return the get/set to be placed on prototype. (If makeObservable() never called, or called
|
|
77
|
+
// late, the non-enumerable property will still be available.)
|
|
50
78
|
return descriptor;
|
|
51
79
|
}
|
package/mobx/overrides.ts
CHANGED
|
@@ -9,9 +9,7 @@ import {
|
|
|
9
9
|
AnnotationsMap,
|
|
10
10
|
CreateObservableOptions,
|
|
11
11
|
makeObservable as baseMakeObservable,
|
|
12
|
-
isObservableProp as baseIsObservableProp
|
|
13
|
-
observable,
|
|
14
|
-
runInAction
|
|
12
|
+
isObservableProp as baseIsObservableProp
|
|
15
13
|
} from 'mobx';
|
|
16
14
|
|
|
17
15
|
/**
|
|
@@ -23,21 +21,10 @@ export function makeObservable(
|
|
|
23
21
|
options?: CreateObservableOptions
|
|
24
22
|
) {
|
|
25
23
|
// Finish creating 'bindable' properties for this instance.
|
|
24
|
+
// Do here to ensure it's enumerable on *instance*
|
|
26
25
|
const bindables = target._xhBindableProperties;
|
|
27
|
-
forEach(bindables, (
|
|
28
|
-
|
|
29
|
-
initVal = target[name];
|
|
30
|
-
target[propName] = isRef ? observable.box(initVal, {deep: false}) : observable.box(initVal);
|
|
31
|
-
Object.defineProperty(target, name, {
|
|
32
|
-
get() {
|
|
33
|
-
return this[propName].get();
|
|
34
|
-
},
|
|
35
|
-
set(v) {
|
|
36
|
-
runInAction(() => this[propName].set(v));
|
|
37
|
-
},
|
|
38
|
-
enumerable: true,
|
|
39
|
-
configurable: true
|
|
40
|
-
});
|
|
26
|
+
forEach(bindables, (descriptor, name) => {
|
|
27
|
+
Object.defineProperty(target, name, descriptor);
|
|
41
28
|
});
|
|
42
29
|
|
|
43
30
|
return baseMakeObservable(target, annotations, options);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xh/hoist",
|
|
3
|
-
"version": "73.0.0-SNAPSHOT.
|
|
3
|
+
"version": "73.0.0-SNAPSHOT.1746826954718",
|
|
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",
|
package/svc/WebSocketService.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {HoistService, PlainObject, XH} from '@xh/hoist/core';
|
|
|
8
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
|
-
import {SECONDS} from '@xh/hoist/utils/datetime';
|
|
11
|
+
import {olderThan, SECONDS} from '@xh/hoist/utils/datetime';
|
|
12
12
|
import {throwIf} from '@xh/hoist/utils/js';
|
|
13
13
|
import {find, pull} from 'lodash';
|
|
14
14
|
|
|
@@ -39,6 +39,13 @@ export class WebSocketService extends HoistService {
|
|
|
39
39
|
static instance: WebSocketService;
|
|
40
40
|
|
|
41
41
|
readonly HEARTBEAT_TOPIC = 'xhHeartbeat';
|
|
42
|
+
/** Check connection and send a new heartbeat (which should be promptly ack'd) every 10s. */
|
|
43
|
+
readonly HEARTBEAT_INTERVAL = 10 * SECONDS;
|
|
44
|
+
/** If no heartbeat ack (or other msg) received for past 30s, assume we are disconnected. */
|
|
45
|
+
readonly HEARTBEAT_ACK_TIMEOUT = 30 * SECONDS;
|
|
46
|
+
/** Wait a well-defined interval before trying to reconnect again. */
|
|
47
|
+
readonly HEARTBEAT_RECONNECT_INTERVAL = 30 * SECONDS;
|
|
48
|
+
|
|
42
49
|
readonly REG_SUCCESS_TOPIC = 'xhRegistrationSuccess';
|
|
43
50
|
readonly FORCE_APP_SUSPEND_TOPIC = 'xhForceAppSuspend';
|
|
44
51
|
readonly REQ_CLIENT_HEALTH_RPT_TOPIC = 'xhRequestClientHealthReport';
|
|
@@ -66,6 +73,7 @@ export class WebSocketService extends HoistService {
|
|
|
66
73
|
private _timer: Timer;
|
|
67
74
|
private _socket: WebSocket;
|
|
68
75
|
private _subsByTopic: Record<string, WebSocketSubscription[]> = {};
|
|
76
|
+
private _lastHeartbeatReconnectAttempt: Date = null;
|
|
69
77
|
|
|
70
78
|
constructor() {
|
|
71
79
|
super();
|
|
@@ -93,7 +101,7 @@ export class WebSocketService extends HoistService {
|
|
|
93
101
|
|
|
94
102
|
this._timer = Timer.create({
|
|
95
103
|
runFn: () => this.heartbeatOrReconnect(),
|
|
96
|
-
interval:
|
|
104
|
+
interval: this.HEARTBEAT_INTERVAL,
|
|
97
105
|
delay: true
|
|
98
106
|
});
|
|
99
107
|
}
|
|
@@ -122,7 +130,6 @@ export class WebSocketService extends HoistService {
|
|
|
122
130
|
|
|
123
131
|
/**
|
|
124
132
|
* Cancel a subscription for a given topic/handler.
|
|
125
|
-
*
|
|
126
133
|
* @param subscription - WebSocketSubscription returned when the subscription was established.
|
|
127
134
|
*/
|
|
128
135
|
unsubscribe(subscription: WebSocketSubscription) {
|
|
@@ -192,9 +199,29 @@ export class WebSocketService extends HoistService {
|
|
|
192
199
|
|
|
193
200
|
private heartbeatOrReconnect() {
|
|
194
201
|
this.updateConnectedStatus();
|
|
202
|
+
|
|
203
|
+
// 1) Detect 'stale' connection. For some reason, not receiving heartbeat.
|
|
204
|
+
// We have a channel key, so we successfully registered with the server and have not
|
|
205
|
+
// received a disconnect message. We should be receiving at least heartbeat messages,
|
|
206
|
+
// but have observed cases where "something" interrupted connectivity in a surprising
|
|
207
|
+
// way and no new inbound messages were arriving, even with the socket reporting open
|
|
208
|
+
// and accepting outbound messages to send. Detect that case here.
|
|
209
|
+
if (this.connected && olderThan(this.lastMessageTime, this.HEARTBEAT_ACK_TIMEOUT)) {
|
|
210
|
+
this.logWarn('Heartbeat response failing - disconnecting');
|
|
211
|
+
this.noteTelemetryEvent('heartbeatFailed');
|
|
212
|
+
this.disconnect();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 2) Happy path - connected+receiving. Send a new heartbeat for server to ack.
|
|
195
216
|
if (this.connected) {
|
|
196
217
|
this.sendMessage({topic: this.HEARTBEAT_TOPIC, data: 'ping'});
|
|
197
|
-
|
|
218
|
+
this.noteTelemetryEvent('heartbeatSent');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 3) Unhappy path -- attempt a (throttled) reconnect.
|
|
223
|
+
if (!olderThan(this._lastHeartbeatReconnectAttempt, this.HEARTBEAT_RECONNECT_INTERVAL)) {
|
|
224
|
+
this._lastHeartbeatReconnectAttempt = new Date();
|
|
198
225
|
this.logWarn('Heartbeat found websocket not connected - attempting to reconnect...');
|
|
199
226
|
this.noteTelemetryEvent('heartbeatReconnectAttempt');
|
|
200
227
|
this.disconnect();
|
|
@@ -252,6 +279,9 @@ export class WebSocketService extends HoistService {
|
|
|
252
279
|
case this.REQ_CLIENT_HEALTH_RPT_TOPIC:
|
|
253
280
|
XH.clientHealthService.sendReportAsync();
|
|
254
281
|
break;
|
|
282
|
+
case this.HEARTBEAT_TOPIC:
|
|
283
|
+
this.noteTelemetryEvent('heartbeatReceived');
|
|
284
|
+
break;
|
|
255
285
|
}
|
|
256
286
|
|
|
257
287
|
this.notifySubscribers(msg);
|
|
@@ -361,6 +391,9 @@ export interface WebSocketTelemetry {
|
|
|
361
391
|
connError?: WebSocketEventTelemetry;
|
|
362
392
|
msgReceived?: WebSocketEventTelemetry;
|
|
363
393
|
msgSent?: WebSocketEventTelemetry;
|
|
394
|
+
heartbeatReceived?: WebSocketEventTelemetry;
|
|
395
|
+
heartbeatSent?: WebSocketEventTelemetry;
|
|
396
|
+
heartbeatFailed?: WebSocketEventTelemetry;
|
|
364
397
|
heartbeatReconnectAttempt?: WebSocketEventTelemetry;
|
|
365
398
|
instanceChangeReconnectAttempt?: WebSocketEventTelemetry;
|
|
366
399
|
};
|