@xiboplayer/xmds 0.6.2 → 0.6.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/xmds",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
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.6.2"
15
+ "@xiboplayer/utils": "0.6.3"
16
16
  },
17
17
  "devDependencies": {
18
18
  "vitest": "^2.0.0"
package/src/index.d.ts CHANGED
@@ -36,4 +36,21 @@ export class XmdsClient {
36
36
  mediaInventory(inventoryXml: string): Promise<boolean>;
37
37
  }
38
38
 
39
+ export class ProtocolDetector {
40
+ constructor(
41
+ cmsUrl: string,
42
+ RestClientClass: typeof RestClient,
43
+ XmdsClientClass: typeof XmdsClient,
44
+ options?: { probeTimeoutMs?: number }
45
+ );
46
+
47
+ protocol: 'rest' | 'xmds' | null;
48
+ lastProbeTime: number;
49
+
50
+ probe(): Promise<boolean>;
51
+ detect(config: any, forceProtocol?: 'rest' | 'xmds'): Promise<{ client: any; protocol: 'rest' | 'xmds' }>;
52
+ reprobe(config: any): Promise<{ client: any; protocol: 'rest' | 'xmds'; changed: boolean }>;
53
+ getProtocol(): 'rest' | 'xmds' | null;
54
+ }
55
+
39
56
  export function parseScheduleResponse(data: any): any;
package/src/index.js CHANGED
@@ -3,4 +3,5 @@ import pkg from '../package.json' with { type: 'json' };
3
3
  export const VERSION = pkg.version;
4
4
  export { RestClient } from './rest-client.js';
5
5
  export { XmdsClient } from './xmds-client.js';
6
+ export { ProtocolDetector } from './protocol-detector.js';
6
7
  export { parseScheduleResponse } from './schedule-parser.js';
@@ -0,0 +1,149 @@
1
+ /**
2
+ * CMS Protocol Auto-Detector
3
+ *
4
+ * Probes the CMS to determine which communication protocol to use:
5
+ * - REST/PlayerApiV2 — optimized JSON protocol (custom CMS image)
6
+ * - SOAP/XMDS — universal XML protocol (any vanilla Xibo CMS)
7
+ *
8
+ * Detection logic:
9
+ * 1. GET {cmsUrl}/api/v2/player/health with a 3-second timeout
10
+ * 2. If 200 + valid JSON → REST
11
+ * 3. If 404/error/timeout → SOAP (fallback)
12
+ *
13
+ * The detected protocol is cached and can be re-probed on connection errors.
14
+ *
15
+ * @example
16
+ * import { ProtocolDetector } from '@xiboplayer/xmds';
17
+ * import { RestClient, XmdsClient } from '@xiboplayer/xmds';
18
+ *
19
+ * const detector = new ProtocolDetector(config.cmsUrl, RestClient, XmdsClient);
20
+ * const xmds = await detector.detect(config);
21
+ * // xmds is either a RestClient or XmdsClient instance
22
+ *
23
+ * // On connection errors, re-probe:
24
+ * const newXmds = await detector.reprobe(config);
25
+ */
26
+
27
+ import { createLogger } from '@xiboplayer/utils';
28
+
29
+ const log = createLogger('Protocol');
30
+
31
+ /** Default probe timeout in milliseconds */
32
+ const PROBE_TIMEOUT_MS = 3000;
33
+
34
+ export class ProtocolDetector {
35
+ /**
36
+ * @param {string} cmsUrl - CMS base URL
37
+ * @param {typeof import('./rest-client.js').RestClient} RestClientClass - RestClient constructor
38
+ * @param {typeof import('./xmds-client.js').XmdsClient} XmdsClientClass - XmdsClient constructor
39
+ * @param {Object} [options]
40
+ * @param {number} [options.probeTimeoutMs=3000] - Timeout for health probe
41
+ */
42
+ constructor(cmsUrl, RestClientClass, XmdsClientClass, options = {}) {
43
+ this.cmsUrl = cmsUrl;
44
+ this.RestClient = RestClientClass;
45
+ this.XmdsClient = XmdsClientClass;
46
+ this.probeTimeoutMs = options.probeTimeoutMs || PROBE_TIMEOUT_MS;
47
+
48
+ /** @type {'rest'|'xmds'|null} Detected protocol (null = not yet probed) */
49
+ this.protocol = null;
50
+
51
+ /** @type {number} Timestamp of last successful probe */
52
+ this.lastProbeTime = 0;
53
+ }
54
+
55
+ /**
56
+ * Probe the CMS health endpoint to determine protocol availability.
57
+ * @returns {Promise<boolean>} true if REST/PlayerApiV2 is available
58
+ */
59
+ async probe() {
60
+ const available = await this.RestClient.isAvailable(this.cmsUrl, {
61
+ maxRetries: 0,
62
+ timeoutMs: this.probeTimeoutMs,
63
+ });
64
+ this.lastProbeTime = Date.now();
65
+ return available;
66
+ }
67
+
68
+ /**
69
+ * Detect the best protocol and create the appropriate client.
70
+ * On first call, probes the CMS. On subsequent calls, returns the cached
71
+ * protocol unless reprobe() is called.
72
+ *
73
+ * @param {Object} config - Player configuration (passed to client constructor)
74
+ * @param {string} [forceProtocol] - 'rest'|'xmds' to skip detection
75
+ * @returns {Promise<{client: any, protocol: 'rest'|'xmds'}>}
76
+ */
77
+ async detect(config, forceProtocol) {
78
+ if (forceProtocol === 'rest') {
79
+ this.protocol = 'rest';
80
+ log.info('Using REST transport (forced)');
81
+ return { client: new this.RestClient(config), protocol: 'rest' };
82
+ }
83
+
84
+ if (forceProtocol === 'xmds') {
85
+ this.protocol = 'xmds';
86
+ log.info('Using XMDS/SOAP transport (forced)');
87
+ return { client: new this.XmdsClient(config), protocol: 'xmds' };
88
+ }
89
+
90
+ // Auto-detect
91
+ log.info('Probing CMS for REST API availability...');
92
+ let isRest = false;
93
+ try {
94
+ isRest = await this.probe();
95
+ } catch (e) {
96
+ log.warn('REST probe failed:', e?.message || e);
97
+ }
98
+
99
+ if (isRest) {
100
+ this.protocol = 'rest';
101
+ log.info('REST transport detected — using PlayerApiV2');
102
+ return { client: new this.RestClient(config), protocol: 'rest' };
103
+ }
104
+
105
+ this.protocol = 'xmds';
106
+ log.info('REST unavailable — using XMDS/SOAP transport');
107
+ return { client: new this.XmdsClient(config), protocol: 'xmds' };
108
+ }
109
+
110
+ /**
111
+ * Re-probe the CMS and potentially switch protocols.
112
+ * Called on connection errors to check if the CMS was upgraded/downgraded.
113
+ *
114
+ * @param {Object} config - Player configuration
115
+ * @returns {Promise<{client: any, protocol: 'rest'|'xmds', changed: boolean}>}
116
+ */
117
+ async reprobe(config) {
118
+ const previousProtocol = this.protocol;
119
+
120
+ log.info('Re-probing CMS protocol...');
121
+ let isRest = false;
122
+ try {
123
+ isRest = await this.probe();
124
+ } catch (e) {
125
+ log.warn('Re-probe failed:', e?.message || e);
126
+ }
127
+
128
+ const newProtocol = isRest ? 'rest' : 'xmds';
129
+ const changed = newProtocol !== previousProtocol;
130
+
131
+ if (changed) {
132
+ log.info(`Protocol changed: ${previousProtocol} → ${newProtocol}`);
133
+ this.protocol = newProtocol;
134
+ const client = isRest ? new this.RestClient(config) : new this.XmdsClient(config);
135
+ return { client, protocol: newProtocol, changed: true };
136
+ }
137
+
138
+ log.info(`Protocol unchanged: ${newProtocol}`);
139
+ return { client: null, protocol: newProtocol, changed: false };
140
+ }
141
+
142
+ /**
143
+ * Get the currently detected protocol.
144
+ * @returns {'rest'|'xmds'|null}
145
+ */
146
+ getProtocol() {
147
+ return this.protocol;
148
+ }
149
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * ProtocolDetector Tests
3
+ *
4
+ * Tests CMS protocol auto-detection logic:
5
+ * - REST probing via RestClient.isAvailable()
6
+ * - Fallback to XMDS/SOAP when REST is unavailable
7
+ * - Forced protocol selection (bypass auto-detection)
8
+ * - Re-probing on connection errors
9
+ */
10
+
11
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
12
+ import { ProtocolDetector } from './protocol-detector.js';
13
+
14
+ describe('ProtocolDetector', () => {
15
+ let MockRestClient;
16
+ let MockXmdsClient;
17
+ let config;
18
+
19
+ beforeEach(() => {
20
+ config = {
21
+ cmsUrl: 'https://cms.example.com',
22
+ cmsKey: 'test-key',
23
+ hardwareKey: 'test-hw',
24
+ };
25
+
26
+ MockRestClient = vi.fn(function (cfg) {
27
+ this.config = cfg;
28
+ this.type = 'rest';
29
+ });
30
+ MockRestClient.isAvailable = vi.fn();
31
+
32
+ MockXmdsClient = vi.fn(function (cfg) {
33
+ this.config = cfg;
34
+ this.type = 'xmds';
35
+ });
36
+ });
37
+
38
+ // ── detect() ─────────────────────────────────────────────────────
39
+
40
+ describe('detect()', () => {
41
+ it('should detect REST when health endpoint returns 200', async () => {
42
+ MockRestClient.isAvailable.mockResolvedValue(true);
43
+
44
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
45
+ const { client, protocol } = await detector.detect(config);
46
+
47
+ expect(protocol).toBe('rest');
48
+ expect(client.type).toBe('rest');
49
+ expect(detector.getProtocol()).toBe('rest');
50
+ expect(MockRestClient.isAvailable).toHaveBeenCalledWith('https://cms.example.com', {
51
+ maxRetries: 0,
52
+ timeoutMs: 3000,
53
+ });
54
+ });
55
+
56
+ it('should fall back to XMDS when REST is unavailable', async () => {
57
+ MockRestClient.isAvailable.mockResolvedValue(false);
58
+
59
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
60
+ const { client, protocol } = await detector.detect(config);
61
+
62
+ expect(protocol).toBe('xmds');
63
+ expect(client.type).toBe('xmds');
64
+ expect(detector.getProtocol()).toBe('xmds');
65
+ });
66
+
67
+ it('should fall back to XMDS when probe throws', async () => {
68
+ MockRestClient.isAvailable.mockRejectedValue(new Error('Network error'));
69
+
70
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
71
+ // probe() will throw, but detect() should handle it
72
+ // Actually, isAvailable catches internally and returns false
73
+ // Let's test the direct throw case by mocking probe
74
+ const { client, protocol } = await detector.detect(config);
75
+
76
+ expect(protocol).toBe('xmds');
77
+ expect(client.type).toBe('xmds');
78
+ });
79
+
80
+ it('should use forced REST protocol without probing', async () => {
81
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
82
+ const { client, protocol } = await detector.detect(config, 'rest');
83
+
84
+ expect(protocol).toBe('rest');
85
+ expect(client.type).toBe('rest');
86
+ expect(MockRestClient.isAvailable).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it('should use forced XMDS protocol without probing', async () => {
90
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
91
+ const { client, protocol } = await detector.detect(config, 'xmds');
92
+
93
+ expect(protocol).toBe('xmds');
94
+ expect(client.type).toBe('xmds');
95
+ expect(MockRestClient.isAvailable).not.toHaveBeenCalled();
96
+ });
97
+
98
+ it('should pass config to client constructor', async () => {
99
+ MockRestClient.isAvailable.mockResolvedValue(true);
100
+
101
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
102
+ const { client } = await detector.detect(config);
103
+
104
+ expect(MockRestClient).toHaveBeenCalledWith(config);
105
+ expect(client.config).toBe(config);
106
+ });
107
+
108
+ it('should use custom probe timeout', async () => {
109
+ MockRestClient.isAvailable.mockResolvedValue(true);
110
+
111
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient, {
112
+ probeTimeoutMs: 5000,
113
+ });
114
+ await detector.detect(config);
115
+
116
+ expect(MockRestClient.isAvailable).toHaveBeenCalledWith('https://cms.example.com', {
117
+ maxRetries: 0,
118
+ timeoutMs: 5000,
119
+ });
120
+ });
121
+
122
+ it('should record lastProbeTime after detection', async () => {
123
+ MockRestClient.isAvailable.mockResolvedValue(true);
124
+
125
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
126
+ expect(detector.lastProbeTime).toBe(0);
127
+
128
+ await detector.detect(config);
129
+
130
+ expect(detector.lastProbeTime).toBeGreaterThan(0);
131
+ expect(detector.lastProbeTime).toBeLessThanOrEqual(Date.now());
132
+ });
133
+ });
134
+
135
+ // ── reprobe() ────────────────────────────────────────────────────
136
+
137
+ describe('reprobe()', () => {
138
+ it('should detect protocol change from XMDS to REST', async () => {
139
+ MockRestClient.isAvailable.mockResolvedValueOnce(false); // initial detect
140
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
141
+ await detector.detect(config);
142
+ expect(detector.getProtocol()).toBe('xmds');
143
+
144
+ MockRestClient.isAvailable.mockResolvedValueOnce(true); // reprobe
145
+ const { client, protocol, changed } = await detector.reprobe(config);
146
+
147
+ expect(changed).toBe(true);
148
+ expect(protocol).toBe('rest');
149
+ expect(client.type).toBe('rest');
150
+ expect(detector.getProtocol()).toBe('rest');
151
+ });
152
+
153
+ it('should detect protocol change from REST to XMDS', async () => {
154
+ MockRestClient.isAvailable.mockResolvedValueOnce(true); // initial detect
155
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
156
+ await detector.detect(config);
157
+ expect(detector.getProtocol()).toBe('rest');
158
+
159
+ MockRestClient.isAvailable.mockResolvedValueOnce(false); // reprobe
160
+ const { client, protocol, changed } = await detector.reprobe(config);
161
+
162
+ expect(changed).toBe(true);
163
+ expect(protocol).toBe('xmds');
164
+ expect(client.type).toBe('xmds');
165
+ expect(detector.getProtocol()).toBe('xmds');
166
+ });
167
+
168
+ it('should return changed=false when protocol is unchanged', async () => {
169
+ MockRestClient.isAvailable.mockResolvedValueOnce(true); // initial detect
170
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
171
+ await detector.detect(config);
172
+
173
+ MockRestClient.isAvailable.mockResolvedValueOnce(true); // reprobe (same)
174
+ const { client, protocol, changed } = await detector.reprobe(config);
175
+
176
+ expect(changed).toBe(false);
177
+ expect(protocol).toBe('rest');
178
+ expect(client).toBeNull(); // No new client when unchanged
179
+ });
180
+
181
+ it('should update lastProbeTime on reprobe', async () => {
182
+ MockRestClient.isAvailable.mockResolvedValue(true);
183
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
184
+ await detector.detect(config);
185
+ const firstProbe = detector.lastProbeTime;
186
+
187
+ // Small delay to ensure different timestamp
188
+ await new Promise(r => setTimeout(r, 5));
189
+
190
+ await detector.reprobe(config);
191
+ expect(detector.lastProbeTime).toBeGreaterThanOrEqual(firstProbe);
192
+ });
193
+ });
194
+
195
+ // ── getProtocol() ────────────────────────────────────────────────
196
+
197
+ describe('getProtocol()', () => {
198
+ it('should return null before detection', () => {
199
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
200
+ expect(detector.getProtocol()).toBeNull();
201
+ });
202
+
203
+ it('should return detected protocol after detect()', async () => {
204
+ MockRestClient.isAvailable.mockResolvedValue(true);
205
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
206
+ await detector.detect(config);
207
+ expect(detector.getProtocol()).toBe('rest');
208
+ });
209
+ });
210
+
211
+ // ── probe() ──────────────────────────────────────────────────────
212
+
213
+ describe('probe()', () => {
214
+ it('should call RestClient.isAvailable with correct URL and timeout', async () => {
215
+ MockRestClient.isAvailable.mockResolvedValue(true);
216
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
217
+ const result = await detector.probe();
218
+
219
+ expect(result).toBe(true);
220
+ expect(MockRestClient.isAvailable).toHaveBeenCalledWith('https://cms.example.com', {
221
+ maxRetries: 0,
222
+ timeoutMs: 3000,
223
+ });
224
+ });
225
+
226
+ it('should return false when REST is unavailable', async () => {
227
+ MockRestClient.isAvailable.mockResolvedValue(false);
228
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
229
+ const result = await detector.probe();
230
+
231
+ expect(result).toBe(false);
232
+ });
233
+ });
234
+ });
@@ -526,7 +526,13 @@ export class RestClient {
526
526
  (window.electronAPI?.isElectron || window.location.hostname === 'localhost');
527
527
  const base = isProxy ? '' : cmsUrl.replace(/\/+$/, '');
528
528
  const url = `${base}${PLAYER_API}/health`;
529
- const response = await fetchWithRetry(url, { method: 'GET' }, retryOptions || { maxRetries: 0 });
529
+ const timeoutMs = retryOptions?.timeoutMs || 3000;
530
+ const fetchOptions = { method: 'GET' };
531
+ // Apply timeout via AbortSignal (short timeout avoids delaying startup)
532
+ if (typeof AbortSignal !== 'undefined' && AbortSignal.timeout) {
533
+ fetchOptions.signal = AbortSignal.timeout(timeoutMs);
534
+ }
535
+ const response = await fetchWithRetry(url, fetchOptions, retryOptions || { maxRetries: 0 });
530
536
  if (!response.ok) return false;
531
537
  const data = await response.json();
532
538
  return data.version === 2 && data.status === 'ok';