@xiboplayer/xmds 0.7.17 → 0.7.19
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/package.json +2 -2
- package/src/protocol-detector.js +154 -25
- package/src/protocol-detector.test.js +180 -6
- package/src/rest-client.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/xmds",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.19",
|
|
4
4
|
"description": "XMDS SOAP client for Xibo CMS communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"./schedule-parser": "./src/schedule-parser.js"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@xiboplayer/utils": "0.7.
|
|
15
|
+
"@xiboplayer/utils": "0.7.19"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"vitest": "^4.1.2"
|
package/src/protocol-detector.js
CHANGED
|
@@ -8,22 +8,30 @@
|
|
|
8
8
|
* - SOAP/XMDS — universal XML protocol (any vanilla Xibo CMS)
|
|
9
9
|
*
|
|
10
10
|
* Detection logic:
|
|
11
|
-
* 1. GET {cmsUrl}${PLAYER_API}/health with a
|
|
11
|
+
* 1. GET {cmsUrl}${PLAYER_API}/health with a generous timeout
|
|
12
12
|
* 2. If 200 + valid JSON → REST
|
|
13
13
|
* 3. If 404/error/timeout → SOAP (fallback)
|
|
14
14
|
*
|
|
15
|
-
*
|
|
15
|
+
* After a fallback to XMDS, the detector can run an automatic background
|
|
16
|
+
* re-probe loop with exponential backoff so the player promotes back to
|
|
17
|
+
* REST as soon as the CMS recovers. The re-probe loop also acts as a
|
|
18
|
+
* visibility signal: each failed probe emits a warning that names the
|
|
19
|
+
* next attempt, so it's obvious at a glance that the player is still
|
|
20
|
+
* running on the wrong transport.
|
|
16
21
|
*
|
|
17
22
|
* @example
|
|
18
|
-
* import { ProtocolDetector } from '@xiboplayer/xmds';
|
|
19
|
-
* import { RestClient, XmdsClient } from '@xiboplayer/xmds';
|
|
23
|
+
* import { ProtocolDetector, RestClient, XmdsClient } from '@xiboplayer/xmds';
|
|
20
24
|
*
|
|
21
25
|
* const detector = new ProtocolDetector(config.cmsUrl, RestClient, XmdsClient);
|
|
22
|
-
* const
|
|
23
|
-
*
|
|
26
|
+
* const { client, protocol } = await detector.detect(config);
|
|
27
|
+
* let xmds = client;
|
|
24
28
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
29
|
+
* if (protocol === 'xmds') {
|
|
30
|
+
* detector.startAutoReprobe(config, (newClient) => {
|
|
31
|
+
* // REST recovered — swap the live client pointer
|
|
32
|
+
* xmds = newClient;
|
|
33
|
+
* });
|
|
34
|
+
* }
|
|
27
35
|
*/
|
|
28
36
|
|
|
29
37
|
import { createLogger } from '@xiboplayer/utils';
|
|
@@ -31,8 +39,30 @@ import { assertCmsClient } from './cms-client.js';
|
|
|
31
39
|
|
|
32
40
|
const log = createLogger('Protocol');
|
|
33
41
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Default timeout for the FIRST probe at startup.
|
|
44
|
+
* Intentionally generous: a cold CMS has to warm up PHP-FPM workers,
|
|
45
|
+
* negotiate TLS, populate OpCache, and possibly fill MariaDB query plan
|
|
46
|
+
* caches. Empirically a healthy cold CMS can take 5-6 seconds on the
|
|
47
|
+
* very first request after a restart — the old 3000ms default was too
|
|
48
|
+
* tight and would lock the player into XMDS fallback on cold start.
|
|
49
|
+
*/
|
|
50
|
+
const FIRST_PROBE_TIMEOUT_MS = 10000;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Default timeout for re-probes after fallback is engaged.
|
|
54
|
+
* Shorter than the first probe because by the time we're re-probing we
|
|
55
|
+
* already know the CMS was reachable at some point — either it's up
|
|
56
|
+
* now or it isn't.
|
|
57
|
+
*/
|
|
58
|
+
const REPROBE_TIMEOUT_MS = 5000;
|
|
59
|
+
|
|
60
|
+
/** Starting delay before the first auto-reprobe after XMDS fallback. */
|
|
61
|
+
const REPROBE_MIN_DELAY_MS = 5000;
|
|
62
|
+
/** Ceiling delay for the backoff — we re-probe at least this often forever. */
|
|
63
|
+
const REPROBE_MAX_DELAY_MS = 120000;
|
|
64
|
+
/** Exponential factor applied after each failed reprobe. */
|
|
65
|
+
const REPROBE_BACKOFF_FACTOR = 2;
|
|
36
66
|
|
|
37
67
|
export class ProtocolDetector {
|
|
38
68
|
/**
|
|
@@ -40,29 +70,50 @@ export class ProtocolDetector {
|
|
|
40
70
|
* @param {typeof import('./rest-client.js').RestClient} RestClientClass - RestClient constructor
|
|
41
71
|
* @param {typeof import('./xmds-client.js').XmdsClient} XmdsClientClass - XmdsClient constructor
|
|
42
72
|
* @param {Object} [options]
|
|
43
|
-
* @param {number} [options.
|
|
73
|
+
* @param {number} [options.firstProbeTimeoutMs=10000] - Timeout for the initial probe
|
|
74
|
+
* @param {number} [options.reprobeTimeoutMs=5000] - Timeout for re-probes after fallback
|
|
75
|
+
* @param {number} [options.probeTimeoutMs] - Back-compat: sets BOTH first and reprobe timeouts to the same value
|
|
76
|
+
* @param {number} [options.reprobeMinDelayMs=5000] - Initial delay before the first auto-reprobe
|
|
77
|
+
* @param {number} [options.reprobeMaxDelayMs=120000] - Ceiling delay between auto-reprobes
|
|
44
78
|
*/
|
|
45
79
|
constructor(cmsUrl, RestClientClass, XmdsClientClass, options = {}) {
|
|
46
80
|
this.cmsUrl = cmsUrl;
|
|
47
81
|
this.RestClient = RestClientClass;
|
|
48
82
|
this.XmdsClient = XmdsClientClass;
|
|
49
|
-
|
|
83
|
+
|
|
84
|
+
// Back-compat: if a single probeTimeoutMs is provided, use it for both
|
|
85
|
+
// first and reprobe. New callers should pass the split options.
|
|
86
|
+
const legacy = options.probeTimeoutMs;
|
|
87
|
+
this.firstProbeTimeoutMs = options.firstProbeTimeoutMs ?? legacy ?? FIRST_PROBE_TIMEOUT_MS;
|
|
88
|
+
this.reprobeTimeoutMs = options.reprobeTimeoutMs ?? legacy ?? REPROBE_TIMEOUT_MS;
|
|
89
|
+
|
|
90
|
+
this.reprobeMinDelayMs = options.reprobeMinDelayMs ?? REPROBE_MIN_DELAY_MS;
|
|
91
|
+
this.reprobeMaxDelayMs = options.reprobeMaxDelayMs ?? REPROBE_MAX_DELAY_MS;
|
|
50
92
|
|
|
51
93
|
/** @type {'rest'|'xmds'|null} Detected protocol (null = not yet probed) */
|
|
52
94
|
this.protocol = null;
|
|
53
95
|
|
|
54
|
-
/** @type {number} Timestamp of last
|
|
96
|
+
/** @type {number} Timestamp of last probe attempt */
|
|
55
97
|
this.lastProbeTime = 0;
|
|
98
|
+
|
|
99
|
+
/** @type {ReturnType<typeof setTimeout>|null} Scheduled auto-reprobe timer */
|
|
100
|
+
this._reprobeTimer = null;
|
|
101
|
+
|
|
102
|
+
/** @type {number} Current backoff delay (ms) — resets on successful probe or stop */
|
|
103
|
+
this._reprobeDelay = this.reprobeMinDelayMs;
|
|
56
104
|
}
|
|
57
105
|
|
|
58
106
|
/**
|
|
59
107
|
* Probe the CMS health endpoint to determine protocol availability.
|
|
108
|
+
* @param {Object} [opts]
|
|
109
|
+
* @param {boolean} [opts.first=false] - Use the longer first-probe timeout
|
|
60
110
|
* @returns {Promise<boolean>} true if REST/PlayerRestApi is available
|
|
61
111
|
*/
|
|
62
|
-
async probe() {
|
|
112
|
+
async probe(opts = {}) {
|
|
113
|
+
const timeoutMs = opts.first ? this.firstProbeTimeoutMs : this.reprobeTimeoutMs;
|
|
63
114
|
const available = await this.RestClient.isAvailable(this.cmsUrl, {
|
|
64
115
|
maxRetries: 0,
|
|
65
|
-
timeoutMs
|
|
116
|
+
timeoutMs,
|
|
66
117
|
});
|
|
67
118
|
this.lastProbeTime = Date.now();
|
|
68
119
|
return available;
|
|
@@ -70,8 +121,8 @@ export class ProtocolDetector {
|
|
|
70
121
|
|
|
71
122
|
/**
|
|
72
123
|
* Detect the best protocol and create the appropriate client.
|
|
73
|
-
* On first call, probes the CMS
|
|
74
|
-
* protocol unless reprobe() is called.
|
|
124
|
+
* On first call, probes the CMS with the generous first-probe timeout.
|
|
125
|
+
* On subsequent calls, returns the cached protocol unless reprobe() is called.
|
|
75
126
|
*
|
|
76
127
|
* @param {Object} config - Player configuration (passed to client constructor)
|
|
77
128
|
* @param {string} [forceProtocol] - 'rest'|'xmds' to skip detection
|
|
@@ -95,10 +146,10 @@ export class ProtocolDetector {
|
|
|
95
146
|
}
|
|
96
147
|
|
|
97
148
|
// Auto-detect
|
|
98
|
-
log.info(
|
|
149
|
+
log.info(`Probing CMS for REST API availability (timeout ${this.firstProbeTimeoutMs}ms)...`);
|
|
99
150
|
let isRest = false;
|
|
100
151
|
try {
|
|
101
|
-
isRest = await this.probe();
|
|
152
|
+
isRest = await this.probe({ first: true });
|
|
102
153
|
} catch (e) {
|
|
103
154
|
log.warn('REST probe failed:', e?.message || e);
|
|
104
155
|
}
|
|
@@ -112,15 +163,16 @@ export class ProtocolDetector {
|
|
|
112
163
|
}
|
|
113
164
|
|
|
114
165
|
this.protocol = 'xmds';
|
|
115
|
-
log.
|
|
166
|
+
log.warn('REST unavailable — falling back to XMDS/SOAP transport');
|
|
116
167
|
const client = new this.XmdsClient(config);
|
|
117
168
|
assertCmsClient(client, 'XmdsClient');
|
|
118
169
|
return { client, protocol: 'xmds' };
|
|
119
170
|
}
|
|
120
171
|
|
|
121
172
|
/**
|
|
122
|
-
* Re-probe the CMS and potentially switch protocols.
|
|
123
|
-
* Called on connection errors
|
|
173
|
+
* Re-probe the CMS once and potentially switch protocols.
|
|
174
|
+
* Called either externally on connection errors, or internally by the
|
|
175
|
+
* auto-reprobe loop.
|
|
124
176
|
*
|
|
125
177
|
* @param {Object} config - Player configuration
|
|
126
178
|
* @returns {Promise<{client: any, protocol: 'rest'|'xmds', changed: boolean}>}
|
|
@@ -128,10 +180,9 @@ export class ProtocolDetector {
|
|
|
128
180
|
async reprobe(config) {
|
|
129
181
|
const previousProtocol = this.protocol;
|
|
130
182
|
|
|
131
|
-
log.info('Re-probing CMS protocol...');
|
|
132
183
|
let isRest = false;
|
|
133
184
|
try {
|
|
134
|
-
isRest = await this.probe();
|
|
185
|
+
isRest = await this.probe({ first: false });
|
|
135
186
|
} catch (e) {
|
|
136
187
|
log.warn('Re-probe failed:', e?.message || e);
|
|
137
188
|
}
|
|
@@ -147,10 +198,88 @@ export class ProtocolDetector {
|
|
|
147
198
|
return { client, protocol: newProtocol, changed: true };
|
|
148
199
|
}
|
|
149
200
|
|
|
150
|
-
log.info(`Protocol unchanged: ${newProtocol}`);
|
|
151
201
|
return { client: null, protocol: newProtocol, changed: false };
|
|
152
202
|
}
|
|
153
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Start an automatic background re-probe loop while on XMDS fallback.
|
|
206
|
+
* Uses exponential backoff from reprobeMinDelayMs up to reprobeMaxDelayMs
|
|
207
|
+
* (defaults: 5s → 2min). Each failed reprobe logs a visibility warning
|
|
208
|
+
* that names the current fallback state and the next probe time, so an
|
|
209
|
+
* operator reading the log after any interval can see at a glance that
|
|
210
|
+
* the player is still running on the wrong transport.
|
|
211
|
+
*
|
|
212
|
+
* When a reprobe succeeds (detects REST is back), the callback is invoked
|
|
213
|
+
* with the new client and the loop stops. No-op if the current protocol
|
|
214
|
+
* is not 'xmds'.
|
|
215
|
+
*
|
|
216
|
+
* @param {Object} config - Player configuration passed to reprobe()
|
|
217
|
+
* @param {(client: any) => void} onRestPromoted - Called when REST recovers
|
|
218
|
+
*/
|
|
219
|
+
startAutoReprobe(config, onRestPromoted) {
|
|
220
|
+
if (this.protocol !== 'xmds') {
|
|
221
|
+
// Only meaningful while in fallback
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// Cancel any existing timer before scheduling a fresh one
|
|
225
|
+
this.stopAutoReprobe();
|
|
226
|
+
this._reprobeDelay = this.reprobeMinDelayMs;
|
|
227
|
+
|
|
228
|
+
const schedule = () => {
|
|
229
|
+
const nextInMs = this._reprobeDelay;
|
|
230
|
+
this._reprobeTimer = setTimeout(async () => {
|
|
231
|
+
this._reprobeTimer = null;
|
|
232
|
+
let result;
|
|
233
|
+
try {
|
|
234
|
+
result = await this.reprobe(config);
|
|
235
|
+
} catch (e) {
|
|
236
|
+
log.warn('Auto-reprobe error:', e?.message || e);
|
|
237
|
+
result = { changed: false, protocol: 'xmds', client: null };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (result.changed && result.protocol === 'rest') {
|
|
241
|
+
log.info('REST recovered — promoting back from XMDS fallback');
|
|
242
|
+
this._reprobeDelay = this.reprobeMinDelayMs;
|
|
243
|
+
try {
|
|
244
|
+
onRestPromoted(result.client);
|
|
245
|
+
} catch (e) {
|
|
246
|
+
log.warn('onRestPromoted callback threw:', e?.message || e);
|
|
247
|
+
}
|
|
248
|
+
return; // stop — we're back on REST
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Still on XMDS — back off and try again
|
|
252
|
+
const nextDelay = Math.min(
|
|
253
|
+
this._reprobeDelay * REPROBE_BACKOFF_FACTOR,
|
|
254
|
+
this.reprobeMaxDelayMs,
|
|
255
|
+
);
|
|
256
|
+
log.warn(
|
|
257
|
+
`Still on XMDS fallback — REST probe failed, next attempt in ${Math.round(nextDelay / 1000)}s`,
|
|
258
|
+
);
|
|
259
|
+
this._reprobeDelay = nextDelay;
|
|
260
|
+
schedule();
|
|
261
|
+
}, nextInMs);
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
log.info(
|
|
265
|
+
`Auto-reprobe scheduled — first attempt in ${Math.round(this._reprobeDelay / 1000)}s (backoff up to ${Math.round(this.reprobeMaxDelayMs / 1000)}s)`,
|
|
266
|
+
);
|
|
267
|
+
schedule();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Cancel any pending auto-reprobe timer. Safe to call multiple times.
|
|
272
|
+
* Call this when the player is shutting down, or when the protocol is
|
|
273
|
+
* swapped back to REST by an external caller.
|
|
274
|
+
*/
|
|
275
|
+
stopAutoReprobe() {
|
|
276
|
+
if (this._reprobeTimer) {
|
|
277
|
+
clearTimeout(this._reprobeTimer);
|
|
278
|
+
this._reprobeTimer = null;
|
|
279
|
+
}
|
|
280
|
+
this._reprobeDelay = this.reprobeMinDelayMs;
|
|
281
|
+
}
|
|
282
|
+
|
|
154
283
|
/**
|
|
155
284
|
* Get the currently detected protocol.
|
|
156
285
|
* @returns {'rest'|'xmds'|null}
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
2
|
// Copyright (c) 2024-2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
// @vitest-environment node
|
|
3
4
|
/**
|
|
4
5
|
* ProtocolDetector Tests
|
|
5
6
|
*
|
|
6
7
|
* Tests CMS protocol auto-detection logic:
|
|
7
|
-
* - REST probing via RestClient.isAvailable()
|
|
8
|
+
* - REST probing via RestClient.isAvailable() with first/reprobe timeout split
|
|
8
9
|
* - Fallback to XMDS/SOAP when REST is unavailable
|
|
9
10
|
* - Forced protocol selection (bypass auto-detection)
|
|
10
11
|
* - Re-probing on connection errors
|
|
12
|
+
* - Automatic background re-probe loop with exponential backoff
|
|
13
|
+
*
|
|
14
|
+
* Pinned to the node environment because this file only exercises JS
|
|
15
|
+
* logic + mocks — no DOM is needed, and jsdom's CJS require of
|
|
16
|
+
* @asamuzakjp/css-color has a top-level-await bug on current Node.
|
|
11
17
|
*/
|
|
12
18
|
|
|
13
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
19
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
14
20
|
import { ProtocolDetector } from './protocol-detector.js';
|
|
15
21
|
import { CMS_CLIENT_METHODS } from './cms-client.js';
|
|
16
22
|
|
|
@@ -59,9 +65,10 @@ describe('ProtocolDetector', () => {
|
|
|
59
65
|
expect(protocol).toBe('rest');
|
|
60
66
|
expect(client.type).toBe('rest');
|
|
61
67
|
expect(detector.getProtocol()).toBe('rest');
|
|
68
|
+
// detect() triggers a FIRST probe with the generous firstProbeTimeoutMs
|
|
62
69
|
expect(MockRestClient.isAvailable).toHaveBeenCalledWith('https://cms.example.com', {
|
|
63
70
|
maxRetries: 0,
|
|
64
|
-
timeoutMs:
|
|
71
|
+
timeoutMs: 10000,
|
|
65
72
|
});
|
|
66
73
|
});
|
|
67
74
|
|
|
@@ -117,9 +124,24 @@ describe('ProtocolDetector', () => {
|
|
|
117
124
|
expect(client.config).toBe(config);
|
|
118
125
|
});
|
|
119
126
|
|
|
120
|
-
it('should use
|
|
127
|
+
it('should use the generous first-probe timeout by default', async () => {
|
|
128
|
+
MockRestClient.isAvailable.mockResolvedValue(true);
|
|
129
|
+
|
|
130
|
+
const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
|
|
131
|
+
await detector.detect(config);
|
|
132
|
+
|
|
133
|
+
// detect() triggers the FIRST probe, which uses firstProbeTimeoutMs (10s)
|
|
134
|
+
// rather than the shorter reprobe timeout. A cold CMS needs headroom.
|
|
135
|
+
expect(MockRestClient.isAvailable).toHaveBeenCalledWith('https://cms.example.com', {
|
|
136
|
+
maxRetries: 0,
|
|
137
|
+
timeoutMs: 10000,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should honour back-compat probeTimeoutMs option', async () => {
|
|
121
142
|
MockRestClient.isAvailable.mockResolvedValue(true);
|
|
122
143
|
|
|
144
|
+
// Single probeTimeoutMs sets BOTH first-probe and reprobe timeouts
|
|
123
145
|
const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient, {
|
|
124
146
|
probeTimeoutMs: 5000,
|
|
125
147
|
});
|
|
@@ -131,6 +153,26 @@ describe('ProtocolDetector', () => {
|
|
|
131
153
|
});
|
|
132
154
|
});
|
|
133
155
|
|
|
156
|
+
it('should honour split firstProbeTimeoutMs and reprobeTimeoutMs', async () => {
|
|
157
|
+
MockRestClient.isAvailable.mockResolvedValue(false);
|
|
158
|
+
|
|
159
|
+
const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient, {
|
|
160
|
+
firstProbeTimeoutMs: 15000,
|
|
161
|
+
reprobeTimeoutMs: 2000,
|
|
162
|
+
});
|
|
163
|
+
await detector.detect(config); // first probe → 15000
|
|
164
|
+
await detector.reprobe(config); // reprobe → 2000
|
|
165
|
+
|
|
166
|
+
expect(MockRestClient.isAvailable).toHaveBeenNthCalledWith(1, 'https://cms.example.com', {
|
|
167
|
+
maxRetries: 0,
|
|
168
|
+
timeoutMs: 15000,
|
|
169
|
+
});
|
|
170
|
+
expect(MockRestClient.isAvailable).toHaveBeenNthCalledWith(2, 'https://cms.example.com', {
|
|
171
|
+
maxRetries: 0,
|
|
172
|
+
timeoutMs: 2000,
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
134
176
|
it('should record lastProbeTime after detection', async () => {
|
|
135
177
|
MockRestClient.isAvailable.mockResolvedValue(true);
|
|
136
178
|
|
|
@@ -223,15 +265,27 @@ describe('ProtocolDetector', () => {
|
|
|
223
265
|
// ── probe() ──────────────────────────────────────────────────────
|
|
224
266
|
|
|
225
267
|
describe('probe()', () => {
|
|
226
|
-
it('should
|
|
268
|
+
it('should default to the reprobe timeout (plain probe() = not first)', async () => {
|
|
227
269
|
MockRestClient.isAvailable.mockResolvedValue(true);
|
|
228
270
|
const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
|
|
229
271
|
const result = await detector.probe();
|
|
230
272
|
|
|
231
273
|
expect(result).toBe(true);
|
|
274
|
+
// Plain probe() with no { first: true } uses the shorter reprobe timeout
|
|
275
|
+
expect(MockRestClient.isAvailable).toHaveBeenCalledWith('https://cms.example.com', {
|
|
276
|
+
maxRetries: 0,
|
|
277
|
+
timeoutMs: 5000,
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('should use firstProbeTimeoutMs when called with { first: true }', async () => {
|
|
282
|
+
MockRestClient.isAvailable.mockResolvedValue(true);
|
|
283
|
+
const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
|
|
284
|
+
await detector.probe({ first: true });
|
|
285
|
+
|
|
232
286
|
expect(MockRestClient.isAvailable).toHaveBeenCalledWith('https://cms.example.com', {
|
|
233
287
|
maxRetries: 0,
|
|
234
|
-
timeoutMs:
|
|
288
|
+
timeoutMs: 10000,
|
|
235
289
|
});
|
|
236
290
|
});
|
|
237
291
|
|
|
@@ -243,4 +297,124 @@ describe('ProtocolDetector', () => {
|
|
|
243
297
|
expect(result).toBe(false);
|
|
244
298
|
});
|
|
245
299
|
});
|
|
300
|
+
|
|
301
|
+
// ── startAutoReprobe() / stopAutoReprobe() ───────────────────────
|
|
302
|
+
|
|
303
|
+
describe('startAutoReprobe()', () => {
|
|
304
|
+
beforeEach(() => {
|
|
305
|
+
vi.useFakeTimers();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
afterEach(() => {
|
|
309
|
+
vi.useRealTimers();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should be a no-op when protocol is not xmds', async () => {
|
|
313
|
+
MockRestClient.isAvailable.mockResolvedValue(true);
|
|
314
|
+
const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
|
|
315
|
+
await detector.detect(config);
|
|
316
|
+
expect(detector.getProtocol()).toBe('rest');
|
|
317
|
+
|
|
318
|
+
const onPromoted = vi.fn();
|
|
319
|
+
detector.startAutoReprobe(config, onPromoted);
|
|
320
|
+
|
|
321
|
+
// No timer should be scheduled
|
|
322
|
+
expect(detector._reprobeTimer).toBeNull();
|
|
323
|
+
vi.advanceTimersByTime(60000);
|
|
324
|
+
expect(onPromoted).not.toHaveBeenCalled();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should promote back to REST and call onRestPromoted when probe succeeds', async () => {
|
|
328
|
+
MockRestClient.isAvailable.mockResolvedValueOnce(false); // initial detect → xmds
|
|
329
|
+
const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
|
|
330
|
+
await detector.detect(config);
|
|
331
|
+
expect(detector.getProtocol()).toBe('xmds');
|
|
332
|
+
|
|
333
|
+
MockRestClient.isAvailable.mockResolvedValueOnce(true); // reprobe → rest
|
|
334
|
+
const onPromoted = vi.fn();
|
|
335
|
+
detector.startAutoReprobe(config, onPromoted);
|
|
336
|
+
|
|
337
|
+
// First auto-reprobe fires after reprobeMinDelayMs (5s default)
|
|
338
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
339
|
+
|
|
340
|
+
expect(onPromoted).toHaveBeenCalledTimes(1);
|
|
341
|
+
expect(onPromoted).toHaveBeenCalledWith(expect.objectContaining({ type: 'rest' }));
|
|
342
|
+
expect(detector.getProtocol()).toBe('rest');
|
|
343
|
+
// Timer is cleared after a successful promotion
|
|
344
|
+
expect(detector._reprobeTimer).toBeNull();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('should follow exponential backoff while REST stays unavailable', async () => {
|
|
348
|
+
MockRestClient.isAvailable.mockResolvedValue(false); // always fails
|
|
349
|
+
const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient, {
|
|
350
|
+
reprobeMinDelayMs: 1000,
|
|
351
|
+
reprobeMaxDelayMs: 8000,
|
|
352
|
+
});
|
|
353
|
+
await detector.detect(config);
|
|
354
|
+
|
|
355
|
+
const onPromoted = vi.fn();
|
|
356
|
+
detector.startAutoReprobe(config, onPromoted);
|
|
357
|
+
|
|
358
|
+
// 1st attempt at 1s
|
|
359
|
+
expect(MockRestClient.isAvailable).toHaveBeenCalledTimes(1); // from detect()
|
|
360
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
361
|
+
expect(MockRestClient.isAvailable).toHaveBeenCalledTimes(2);
|
|
362
|
+
|
|
363
|
+
// 2nd attempt at 1 + 2 = 3s (doubled)
|
|
364
|
+
await vi.advanceTimersByTimeAsync(2000);
|
|
365
|
+
expect(MockRestClient.isAvailable).toHaveBeenCalledTimes(3);
|
|
366
|
+
|
|
367
|
+
// 3rd attempt at 3 + 4 = 7s
|
|
368
|
+
await vi.advanceTimersByTimeAsync(4000);
|
|
369
|
+
expect(MockRestClient.isAvailable).toHaveBeenCalledTimes(4);
|
|
370
|
+
|
|
371
|
+
// 4th attempt at 7 + 8 = 15s (capped at max)
|
|
372
|
+
await vi.advanceTimersByTimeAsync(8000);
|
|
373
|
+
expect(MockRestClient.isAvailable).toHaveBeenCalledTimes(5);
|
|
374
|
+
|
|
375
|
+
// 5th at +8s (still capped)
|
|
376
|
+
await vi.advanceTimersByTimeAsync(8000);
|
|
377
|
+
expect(MockRestClient.isAvailable).toHaveBeenCalledTimes(6);
|
|
378
|
+
|
|
379
|
+
expect(onPromoted).not.toHaveBeenCalled();
|
|
380
|
+
detector.stopAutoReprobe();
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('should cancel the pending timer when stopAutoReprobe is called', async () => {
|
|
384
|
+
MockRestClient.isAvailable.mockResolvedValue(false);
|
|
385
|
+
const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
|
|
386
|
+
await detector.detect(config);
|
|
387
|
+
|
|
388
|
+
const onPromoted = vi.fn();
|
|
389
|
+
detector.startAutoReprobe(config, onPromoted);
|
|
390
|
+
expect(detector._reprobeTimer).not.toBeNull();
|
|
391
|
+
|
|
392
|
+
detector.stopAutoReprobe();
|
|
393
|
+
expect(detector._reprobeTimer).toBeNull();
|
|
394
|
+
|
|
395
|
+
// Advancing the clock should trigger no further calls
|
|
396
|
+
const callsBefore = MockRestClient.isAvailable.mock.calls.length;
|
|
397
|
+
await vi.advanceTimersByTimeAsync(300000); // 5 minutes
|
|
398
|
+
expect(MockRestClient.isAvailable.mock.calls.length).toBe(callsBefore);
|
|
399
|
+
expect(onPromoted).not.toHaveBeenCalled();
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('should restart cleanly when startAutoReprobe is called twice', async () => {
|
|
403
|
+
MockRestClient.isAvailable.mockResolvedValue(false);
|
|
404
|
+
const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient, {
|
|
405
|
+
reprobeMinDelayMs: 1000,
|
|
406
|
+
reprobeMaxDelayMs: 4000,
|
|
407
|
+
});
|
|
408
|
+
await detector.detect(config);
|
|
409
|
+
|
|
410
|
+
const onPromoted = vi.fn();
|
|
411
|
+
detector.startAutoReprobe(config, onPromoted);
|
|
412
|
+
// Advance once so delay doubles internally
|
|
413
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
414
|
+
|
|
415
|
+
// Restart — delay should reset to minimum
|
|
416
|
+
detector.startAutoReprobe(config, onPromoted);
|
|
417
|
+
expect(detector._reprobeDelay).toBe(1000);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
246
420
|
});
|
package/src/rest-client.js
CHANGED
|
@@ -250,7 +250,7 @@ export class RestClient {
|
|
|
250
250
|
clientCode: this.config.clientCode || 400,
|
|
251
251
|
operatingSystem: os,
|
|
252
252
|
macAddress: this.config.macAddress || 'n/a',
|
|
253
|
-
xmrChannel: this.config.xmrChannel,
|
|
253
|
+
xmrChannel: this.config.xmrChannel || '',
|
|
254
254
|
xmrPubKey: this.config.xmrPubKey || '',
|
|
255
255
|
});
|
|
256
256
|
|