@xiboplayer/xmds 0.6.2 → 0.6.4

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/README.md CHANGED
@@ -1,15 +1,42 @@
1
1
  # @xiboplayer/xmds
2
2
 
3
- **XMDS SOAP + REST client for Xibo CMS communication.**
3
+ **XMDS/REST dual-transport CMS client for Xibo digital signage -- auto-detects REST or SOAP based on CMS capabilities.**
4
4
 
5
5
  ## Overview
6
6
 
7
- Dual-transport client supporting both Xibo communication protocols:
7
+ Unified abstraction over Xibo's two communication protocols:
8
8
 
9
- - **XMDS SOAP** (v3-v7) standard Xibo player protocol with XML encoding
10
- - **REST API** lighter JSON transport (~30% smaller payloads) with ETag caching
9
+ - **REST API v2** -- JSON-based, JWT auth, ETag caching, ~30% smaller payloads
10
+ - **XMDS SOAP** (v3-v7) -- XML-based, universal compatibility with all Xibo CMS versions
11
11
 
12
- Both transports expose the same API. The REST client is preferred when available.
12
+ Both expose an identical public API. At startup, `ProtocolDetector` probes the CMS to select the optimal transport -- fallback to SOAP if REST is unavailable.
13
+
14
+ ### Capabilities
15
+
16
+ - **Dual-transport abstraction** -- RestClient and XmdsClient implement the same interface
17
+ - **Auto-detection** -- quick health probe (3s timeout) to select REST or SOAP
18
+ - **HTTP caching** -- REST client uses ETags to avoid redundant GETs
19
+ - **Retry & backoff** -- exponential backoff with jitter (default: 2 retries, 2s base delay)
20
+ - **JWT auth** -- REST client auto-refreshes tokens 60s before expiry
21
+ - **CRC checksums** -- `checkRf` and `checkSchedule` allow skipping unchanged data
22
+ - **Delegated reporting** -- stats/logs can be submitted on behalf of other displays (follower -> lead delegation)
23
+
24
+ ## Architecture
25
+
26
+ ```
27
+ Player Core Transport Selection CMS
28
+ ----------- -------------------- -----
29
+
30
+ registerDisplay() Is REST available?
31
+ requiredFiles() Same API (GET /api/v2/player/health)
32
+ schedule() Yes --> RestClient (JWT, ETag) --> /api/v2/player/*
33
+ getResource() No --> XmdsClient (SOAP XML) --> /xmds.php
34
+ notify()
35
+ submitStats()
36
+ submitLog()
37
+ ```
38
+
39
+ Both clients share the same return types. The scheduler, renderer, and sync modules consume only the CmsClient interface -- they're transport-agnostic.
13
40
 
14
41
  ## Installation
15
42
 
@@ -19,37 +46,164 @@ npm install @xiboplayer/xmds
19
46
 
20
47
  ## Usage
21
48
 
49
+ ### Auto-detect and instantiate
50
+
51
+ ```javascript
52
+ import { ProtocolDetector, RestClient, XmdsClient } from '@xiboplayer/xmds';
53
+
54
+ const detector = new ProtocolDetector(cmsUrl, RestClient, XmdsClient);
55
+ const { client, protocol } = await detector.detect({
56
+ cmsUrl: 'https://cms.example.com',
57
+ cmsKey: 'your-server-key',
58
+ hardwareKey: 'display-123',
59
+ displayName: 'Main Screen',
60
+ });
61
+
62
+ console.log(`Using ${protocol} transport`);
63
+
64
+ const display = await client.registerDisplay();
65
+ const files = await client.requiredFiles();
66
+ const schedule = await client.schedule();
67
+ ```
68
+
69
+ ### Force a specific transport
70
+
71
+ ```javascript
72
+ const { client } = await detector.detect(config, 'xmds'); // Force SOAP
73
+ const { client } = await detector.detect(config, 'rest'); // Force REST
74
+ ```
75
+
76
+ ### Direct RestClient usage
77
+
22
78
  ```javascript
23
79
  import { RestClient } from '@xiboplayer/xmds';
24
80
 
25
81
  const client = new RestClient({
26
- cmsUrl: 'https://your-cms.example.com',
27
- serverKey: 'your-key',
28
- hardwareKey: 'display-id',
82
+ cmsUrl: 'https://cms.example.com',
83
+ cmsKey: 'server-key',
84
+ hardwareKey: 'display-key',
85
+ displayName: 'Display 1',
29
86
  });
30
87
 
31
- const result = await client.registerDisplay();
32
- const files = await client.requiredFiles();
33
- const schedule = await client.schedule();
88
+ const { code, message, settings, syncConfig } = await client.registerDisplay();
89
+ const { files, purge } = await client.requiredFiles();
90
+ ```
91
+
92
+ ### Direct XmdsClient usage
93
+
94
+ ```javascript
95
+ import { XmdsClient } from '@xiboplayer/xmds';
96
+
97
+ const client = new XmdsClient({
98
+ cmsUrl: 'https://cms.example.com',
99
+ cmsKey: 'server-key',
100
+ hardwareKey: 'display-key',
101
+ displayName: 'Display 1',
102
+ xmrChannel: 'ch-123',
103
+ xmrPubKey: '-----BEGIN PUBLIC KEY-----\n...',
104
+ });
105
+
106
+ const display = await client.registerDisplay();
107
+ ```
108
+
109
+ ### Parse schedule without network call
110
+
111
+ ```javascript
112
+ import { parseScheduleResponse } from '@xiboplayer/xmds';
113
+
114
+ const parsed = parseScheduleResponse(scheduleXml);
115
+ // { default, layouts, campaigns, overlays, actions, commands, dataConnectors }
34
116
  ```
35
117
 
36
118
  ## Methods
37
119
 
38
- | Method | Description |
39
- |--------|-------------|
40
- | `registerDisplay()` | Register/authorize the display with the CMS |
41
- | `requiredFiles()` | Get list of required media files and layouts |
42
- | `schedule()` | Get the current schedule XML |
43
- | `getResource(regionId, mediaId)` | Get rendered widget HTML |
44
- | `notifyStatus(status)` | Report display status to CMS |
45
- | `mediaInventory(inventory)` | Report cached media inventory |
46
- | `submitStats(stats, hardwareKeyOverride?)` | Submit proof of play statistics (optional `hardwareKeyOverride` for delegated submissions on behalf of another display) |
47
- | `submitScreenShot(base64)` | Upload a screenshot to the CMS |
48
- | `submitLog(logs, hardwareKeyOverride?)` | Submit display logs (optional `hardwareKeyOverride` for delegated submissions on behalf of another display) |
120
+ All methods available on both `RestClient` and `XmdsClient` with identical signatures:
121
+
122
+ | Method | Parameters | Returns | Description |
123
+ |--------|------------|---------|-------------|
124
+ | `registerDisplay()` | -- | `RegisterDisplayResult` | Authenticate, get settings, tags, commands, sync config |
125
+ | `requiredFiles()` | -- | `RequiredFilesResult` | Get media, layouts, widgets, and files to purge |
126
+ | `schedule()` | -- | `ScheduleObject` | Get complete schedule |
127
+ | `getResource(layoutId, regionId, mediaId)` | number x 3 | `string` | Get rendered widget HTML |
128
+ | `notifyStatus(status)` | Object | JSON/XML | Report display status |
129
+ | `mediaInventory(inventoryXml)` | string/Array | JSON/XML | Report cached media |
130
+ | `submitStats(statsXml, hardwareKeyOverride?)` | string, string? | boolean | Submit proof-of-play (optional override for delegated reporting) |
131
+ | `submitLog(logXml, hardwareKeyOverride?)` | string, string? | boolean | Submit logs (optional override for delegated reporting) |
132
+ | `submitScreenShot(base64Image)` | string | boolean | Upload screenshot |
133
+ | `reportFaults(faultJson)` | string/Object | boolean | Report hardware/software faults |
134
+ | `blackList(mediaId, type, reason)` | string, string, string | boolean | Blacklist broken media |
135
+ | `getWeather()` | -- | JSON/XML | Get weather data for schedule criteria |
136
+
137
+ ### Key Response Types
138
+
139
+ **RegisterDisplayResult:**
140
+ ```typescript
141
+ {
142
+ code: 'READY' | 'WRONG_SCHEDULE_KEY' | 'DISPLAY_NOT_LICENSED' | ...,
143
+ message: string,
144
+ settings: { [key: string]: any },
145
+ tags: string[],
146
+ commands: Array<{ commandCode, commandString }>,
147
+ checkRf: string, // CRC32 of RequiredFiles (skip if unchanged)
148
+ checkSchedule: string, // CRC32 of Schedule (skip if unchanged)
149
+ syncConfig: { // Multi-display sync (null if not enabled)
150
+ syncGroup: string,
151
+ syncPublisherPort: number,
152
+ syncSwitchDelay: number,
153
+ isLead: boolean
154
+ } | null
155
+ }
156
+ ```
157
+
158
+ ## Transport Comparison
159
+
160
+ | Aspect | REST (v2) | XMDS (SOAP) |
161
+ |--------|-----------|-------------|
162
+ | **Protocol** | JSON over HTTP | XML-RPC over HTTP |
163
+ | **Auth** | JWT (Bearer token) | Per-request params (serverKey) |
164
+ | **Payload size** | ~30% smaller | Baseline |
165
+ | **Caching** | ETags + response cache | No caching |
166
+ | **Availability** | Custom CMS images (Xibo 3.0+) | All Xibo versions (v3-v7+) |
167
+ | **Fallback** | SOAP (automatic) | None |
168
+
169
+ ## Error Handling
170
+
171
+ **Retry:** Both clients use `fetchWithRetry()` with exponential backoff (2 retries, 2s base delay).
172
+
173
+ **Token expiry (REST):** 401 response triggers automatic re-authentication and request retry.
174
+
175
+ **SOAP faults:** XmdsClient parses `<soap:Fault>` and throws with fault message.
176
+
177
+ **Custom retry strategy:**
178
+ ```javascript
179
+ const client = new RestClient({
180
+ ...config,
181
+ retryOptions: { maxRetries: 5, baseDelayMs: 5000 }
182
+ });
183
+ ```
184
+
185
+ ## Constructor Options
186
+
187
+ ```typescript
188
+ {
189
+ cmsUrl: string, // Base URL of Xibo CMS
190
+ cmsKey: string, // Server authentication key
191
+ hardwareKey: string, // Unique display identifier
192
+ displayName?: string, // Human-readable display name
193
+ clientVersion?: string, // Default: '0.1.0'
194
+ clientType?: string, // Default: 'linux'
195
+ xmrChannel?: string, // XMR channel ID
196
+ xmrPubKey?: string, // XMR public key (PEM)
197
+ retryOptions?: {
198
+ maxRetries?: number, // Default: 2
199
+ baseDelayMs?: number // Default: 2000
200
+ }
201
+ }
202
+ ```
49
203
 
50
204
  ## Dependencies
51
205
 
52
- - `@xiboplayer/utils` logger, events, fetchWithRetry
206
+ - `@xiboplayer/utils` -- logger, fetchWithRetry
53
207
 
54
208
  ---
55
209
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/xmds",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
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.4"
16
16
  },
17
17
  "devDependencies": {
18
18
  "vitest": "^2.0.0"
@@ -0,0 +1,89 @@
1
+ /**
2
+ * CmsClient interface definition — the unified contract for CMS communication.
3
+ *
4
+ * Both RestClient and XmdsClient implement this interface identically.
5
+ * PlayerCore calls these methods without knowing which transport is underneath.
6
+ * ProtocolDetector selects the implementation at startup.
7
+ *
8
+ * This file provides:
9
+ * 1. JSDoc type definitions for the interface
10
+ * 2. CMS_CLIENT_METHODS — canonical list of required methods
11
+ * 3. assertCmsClient() — runtime conformance check
12
+ *
13
+ * @example
14
+ * import { ProtocolDetector, RestClient, XmdsClient } from '@xiboplayer/xmds';
15
+ *
16
+ * const detector = new ProtocolDetector(cmsUrl, RestClient, XmdsClient);
17
+ * const { client } = await detector.detect(config);
18
+ * // client implements CmsClient — PlayerCore doesn't care which one
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} RegisterDisplayResult
23
+ * @property {string} code - 'READY' or error code
24
+ * @property {string} message - Human-readable status message
25
+ * @property {Object|null} settings - Display settings (null if not READY)
26
+ * @property {string[]} tags - Display tags
27
+ * @property {Array<{commandCode: string, commandString: string}>} commands - Scheduled commands
28
+ * @property {Object} displayAttrs - Server-provided attributes (date, timezone, status)
29
+ * @property {string} checkRf - CRC checksum for RequiredFiles (skip if unchanged)
30
+ * @property {string} checkSchedule - CRC checksum for Schedule (skip if unchanged)
31
+ * @property {Object|null} syncConfig - Multi-display sync configuration
32
+ */
33
+
34
+ /**
35
+ * @typedef {Object} RequiredFilesResult
36
+ * @property {Array<{type: string, id: string, size: number, md5: string, download: string, path: string, saveAs: string|null}>} files
37
+ * @property {Array<{id: string, storedAs: string}>} purge - Files to delete
38
+ */
39
+
40
+ /**
41
+ * @typedef {Object} CmsClient
42
+ * @property {() => Promise<RegisterDisplayResult>} registerDisplay
43
+ * @property {() => Promise<RequiredFilesResult>} requiredFiles
44
+ * @property {() => Promise<Object>} schedule
45
+ * @property {(layoutId: string, regionId: string, mediaId: string) => Promise<string>} getResource
46
+ * @property {(status: Object) => Promise<any>} notifyStatus
47
+ * @property {(inventoryXml: string|Array) => Promise<any>} mediaInventory
48
+ * @property {(mediaId: string, type: string, reason: string) => Promise<boolean>} blackList
49
+ * @property {(logXml: string|Array, hardwareKeyOverride?: string) => Promise<boolean>} submitLog
50
+ * @property {(base64Image: string) => Promise<boolean>} submitScreenShot
51
+ * @property {(statsXml: string|Array, hardwareKeyOverride?: string) => Promise<boolean>} submitStats
52
+ * @property {(faultJson: string|Object) => Promise<boolean>} reportFaults
53
+ * @property {() => Promise<Object>} getWeather
54
+ */
55
+
56
+ /**
57
+ * Canonical list of methods that every CmsClient implementation must provide.
58
+ * Used for runtime conformance checks and test assertions.
59
+ */
60
+ export const CMS_CLIENT_METHODS = [
61
+ 'registerDisplay',
62
+ 'requiredFiles',
63
+ 'schedule',
64
+ 'getResource',
65
+ 'notifyStatus',
66
+ 'mediaInventory',
67
+ 'blackList',
68
+ 'submitLog',
69
+ 'submitScreenShot',
70
+ 'submitStats',
71
+ 'reportFaults',
72
+ 'getWeather',
73
+ ];
74
+
75
+ /**
76
+ * Verify that an object implements the CmsClient interface.
77
+ * Throws if any required method is missing or not a function.
78
+ *
79
+ * @param {any} client - Object to check
80
+ * @param {string} [label] - Label for error messages (e.g. 'RestClient')
81
+ * @throws {Error} If the client doesn't conform
82
+ */
83
+ export function assertCmsClient(client, label = 'client') {
84
+ for (const method of CMS_CLIENT_METHODS) {
85
+ if (typeof client[method] !== 'function') {
86
+ throw new Error(`${label} missing CmsClient method: ${method}()`);
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,51 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect } from 'vitest';
3
+ import { CMS_CLIENT_METHODS, assertCmsClient } from './cms-client.js';
4
+ import { RestClient } from './rest-client.js';
5
+ import { XmdsClient } from './xmds-client.js';
6
+
7
+ const mockConfig = {
8
+ cmsUrl: 'https://example.com',
9
+ cmsKey: 'test',
10
+ hardwareKey: 'hw-001',
11
+ displayName: 'Test Display',
12
+ xmrChannel: '',
13
+ };
14
+
15
+ describe('CmsClient interface conformance', () => {
16
+ const restClient = new RestClient(mockConfig);
17
+ const xmdsClient = new XmdsClient(mockConfig);
18
+
19
+ it('CMS_CLIENT_METHODS lists 12 methods', () => {
20
+ expect(CMS_CLIENT_METHODS).toHaveLength(12);
21
+ });
22
+
23
+ for (const method of CMS_CLIENT_METHODS) {
24
+ it(`RestClient implements ${method}()`, () => {
25
+ expect(typeof restClient[method]).toBe('function');
26
+ });
27
+
28
+ it(`XmdsClient implements ${method}()`, () => {
29
+ expect(typeof xmdsClient[method]).toBe('function');
30
+ });
31
+ }
32
+
33
+ it('assertCmsClient passes for RestClient', () => {
34
+ expect(() => assertCmsClient(restClient, 'RestClient')).not.toThrow();
35
+ });
36
+
37
+ it('assertCmsClient passes for XmdsClient', () => {
38
+ expect(() => assertCmsClient(xmdsClient, 'XmdsClient')).not.toThrow();
39
+ });
40
+
41
+ it('assertCmsClient throws for incomplete client', () => {
42
+ const partial = { registerDisplay: () => {} };
43
+ expect(() => assertCmsClient(partial, 'partial')).toThrow('partial missing CmsClient method');
44
+ });
45
+
46
+ it('assertCmsClient throws for non-function property', () => {
47
+ const bad = Object.fromEntries(CMS_CLIENT_METHODS.map(m => [m, () => {}]));
48
+ bad.schedule = 'not a function';
49
+ expect(() => assertCmsClient(bad, 'bad')).toThrow('bad missing CmsClient method: schedule()');
50
+ });
51
+ });
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,6 @@ 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';
7
+ export { CMS_CLIENT_METHODS, assertCmsClient } from './cms-client.js';
6
8
  export { parseScheduleResponse } from './schedule-parser.js';
@@ -0,0 +1,159 @@
1
+ /**
2
+ * CMS Protocol Auto-Detector
3
+ *
4
+ * Probes the CMS to determine which communication protocol to use:
5
+ * - REST/PlayerRestApi — optimized JSON protocol (custom CMS image)
6
+ * - SOAP/XMDS — universal XML protocol (any vanilla Xibo CMS)
7
+ *
8
+ * Detection logic:
9
+ * 1. GET {cmsUrl}${PLAYER_API}/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
+ import { assertCmsClient } from './cms-client.js';
29
+
30
+ const log = createLogger('Protocol');
31
+
32
+ /** Default probe timeout in milliseconds */
33
+ const PROBE_TIMEOUT_MS = 3000;
34
+
35
+ export class ProtocolDetector {
36
+ /**
37
+ * @param {string} cmsUrl - CMS base URL
38
+ * @param {typeof import('./rest-client.js').RestClient} RestClientClass - RestClient constructor
39
+ * @param {typeof import('./xmds-client.js').XmdsClient} XmdsClientClass - XmdsClient constructor
40
+ * @param {Object} [options]
41
+ * @param {number} [options.probeTimeoutMs=3000] - Timeout for health probe
42
+ */
43
+ constructor(cmsUrl, RestClientClass, XmdsClientClass, options = {}) {
44
+ this.cmsUrl = cmsUrl;
45
+ this.RestClient = RestClientClass;
46
+ this.XmdsClient = XmdsClientClass;
47
+ this.probeTimeoutMs = options.probeTimeoutMs || PROBE_TIMEOUT_MS;
48
+
49
+ /** @type {'rest'|'xmds'|null} Detected protocol (null = not yet probed) */
50
+ this.protocol = null;
51
+
52
+ /** @type {number} Timestamp of last successful probe */
53
+ this.lastProbeTime = 0;
54
+ }
55
+
56
+ /**
57
+ * Probe the CMS health endpoint to determine protocol availability.
58
+ * @returns {Promise<boolean>} true if REST/PlayerRestApi is available
59
+ */
60
+ async probe() {
61
+ const available = await this.RestClient.isAvailable(this.cmsUrl, {
62
+ maxRetries: 0,
63
+ timeoutMs: this.probeTimeoutMs,
64
+ });
65
+ this.lastProbeTime = Date.now();
66
+ return available;
67
+ }
68
+
69
+ /**
70
+ * Detect the best protocol and create the appropriate client.
71
+ * On first call, probes the CMS. On subsequent calls, returns the cached
72
+ * protocol unless reprobe() is called.
73
+ *
74
+ * @param {Object} config - Player configuration (passed to client constructor)
75
+ * @param {string} [forceProtocol] - 'rest'|'xmds' to skip detection
76
+ * @returns {Promise<{client: any, protocol: 'rest'|'xmds'}>}
77
+ */
78
+ async detect(config, forceProtocol) {
79
+ if (forceProtocol === 'rest') {
80
+ this.protocol = 'rest';
81
+ log.info('Using REST transport (forced)');
82
+ const client = new this.RestClient(config);
83
+ assertCmsClient(client, 'RestClient');
84
+ return { client, protocol: 'rest' };
85
+ }
86
+
87
+ if (forceProtocol === 'xmds') {
88
+ this.protocol = 'xmds';
89
+ log.info('Using XMDS/SOAP transport (forced)');
90
+ const client = new this.XmdsClient(config);
91
+ assertCmsClient(client, 'XmdsClient');
92
+ return { client, protocol: 'xmds' };
93
+ }
94
+
95
+ // Auto-detect
96
+ log.info('Probing CMS for REST API availability...');
97
+ let isRest = false;
98
+ try {
99
+ isRest = await this.probe();
100
+ } catch (e) {
101
+ log.warn('REST probe failed:', e?.message || e);
102
+ }
103
+
104
+ if (isRest) {
105
+ this.protocol = 'rest';
106
+ log.info('REST transport detected — using PlayerRestApi');
107
+ const client = new this.RestClient(config);
108
+ assertCmsClient(client, 'RestClient');
109
+ return { client, protocol: 'rest' };
110
+ }
111
+
112
+ this.protocol = 'xmds';
113
+ log.info('REST unavailable — using XMDS/SOAP transport');
114
+ const client = new this.XmdsClient(config);
115
+ assertCmsClient(client, 'XmdsClient');
116
+ return { client, protocol: 'xmds' };
117
+ }
118
+
119
+ /**
120
+ * Re-probe the CMS and potentially switch protocols.
121
+ * Called on connection errors to check if the CMS was upgraded/downgraded.
122
+ *
123
+ * @param {Object} config - Player configuration
124
+ * @returns {Promise<{client: any, protocol: 'rest'|'xmds', changed: boolean}>}
125
+ */
126
+ async reprobe(config) {
127
+ const previousProtocol = this.protocol;
128
+
129
+ log.info('Re-probing CMS protocol...');
130
+ let isRest = false;
131
+ try {
132
+ isRest = await this.probe();
133
+ } catch (e) {
134
+ log.warn('Re-probe failed:', e?.message || e);
135
+ }
136
+
137
+ const newProtocol = isRest ? 'rest' : 'xmds';
138
+ const changed = newProtocol !== previousProtocol;
139
+
140
+ if (changed) {
141
+ log.info(`Protocol changed: ${previousProtocol} → ${newProtocol}`);
142
+ this.protocol = newProtocol;
143
+ const client = isRest ? new this.RestClient(config) : new this.XmdsClient(config);
144
+ assertCmsClient(client, isRest ? 'RestClient' : 'XmdsClient');
145
+ return { client, protocol: newProtocol, changed: true };
146
+ }
147
+
148
+ log.info(`Protocol unchanged: ${newProtocol}`);
149
+ return { client: null, protocol: newProtocol, changed: false };
150
+ }
151
+
152
+ /**
153
+ * Get the currently detected protocol.
154
+ * @returns {'rest'|'xmds'|null}
155
+ */
156
+ getProtocol() {
157
+ return this.protocol;
158
+ }
159
+ }
@@ -0,0 +1,244 @@
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
+ import { CMS_CLIENT_METHODS } from './cms-client.js';
14
+
15
+ // Stub all CmsClient methods on a mock constructor's prototype
16
+ function addCmsClientStubs(MockClass) {
17
+ for (const method of CMS_CLIENT_METHODS) {
18
+ MockClass.prototype[method] = vi.fn();
19
+ }
20
+ }
21
+
22
+ describe('ProtocolDetector', () => {
23
+ let MockRestClient;
24
+ let MockXmdsClient;
25
+ let config;
26
+
27
+ beforeEach(() => {
28
+ config = {
29
+ cmsUrl: 'https://cms.example.com',
30
+ cmsKey: 'test-key',
31
+ hardwareKey: 'test-hw',
32
+ };
33
+
34
+ MockRestClient = vi.fn(function (cfg) {
35
+ this.config = cfg;
36
+ this.type = 'rest';
37
+ });
38
+ MockRestClient.isAvailable = vi.fn();
39
+ addCmsClientStubs(MockRestClient);
40
+
41
+ MockXmdsClient = vi.fn(function (cfg) {
42
+ this.config = cfg;
43
+ this.type = 'xmds';
44
+ });
45
+ addCmsClientStubs(MockXmdsClient);
46
+ });
47
+
48
+ // ── detect() ─────────────────────────────────────────────────────
49
+
50
+ describe('detect()', () => {
51
+ it('should detect REST when health endpoint returns 200', async () => {
52
+ MockRestClient.isAvailable.mockResolvedValue(true);
53
+
54
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
55
+ const { client, protocol } = await detector.detect(config);
56
+
57
+ expect(protocol).toBe('rest');
58
+ expect(client.type).toBe('rest');
59
+ expect(detector.getProtocol()).toBe('rest');
60
+ expect(MockRestClient.isAvailable).toHaveBeenCalledWith('https://cms.example.com', {
61
+ maxRetries: 0,
62
+ timeoutMs: 3000,
63
+ });
64
+ });
65
+
66
+ it('should fall back to XMDS when REST is unavailable', async () => {
67
+ MockRestClient.isAvailable.mockResolvedValue(false);
68
+
69
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
70
+ const { client, protocol } = await detector.detect(config);
71
+
72
+ expect(protocol).toBe('xmds');
73
+ expect(client.type).toBe('xmds');
74
+ expect(detector.getProtocol()).toBe('xmds');
75
+ });
76
+
77
+ it('should fall back to XMDS when probe throws', async () => {
78
+ MockRestClient.isAvailable.mockRejectedValue(new Error('Network error'));
79
+
80
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
81
+ // probe() will throw, but detect() should handle it
82
+ // Actually, isAvailable catches internally and returns false
83
+ // Let's test the direct throw case by mocking probe
84
+ const { client, protocol } = await detector.detect(config);
85
+
86
+ expect(protocol).toBe('xmds');
87
+ expect(client.type).toBe('xmds');
88
+ });
89
+
90
+ it('should use forced REST protocol without probing', async () => {
91
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
92
+ const { client, protocol } = await detector.detect(config, 'rest');
93
+
94
+ expect(protocol).toBe('rest');
95
+ expect(client.type).toBe('rest');
96
+ expect(MockRestClient.isAvailable).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it('should use forced XMDS protocol without probing', async () => {
100
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
101
+ const { client, protocol } = await detector.detect(config, 'xmds');
102
+
103
+ expect(protocol).toBe('xmds');
104
+ expect(client.type).toBe('xmds');
105
+ expect(MockRestClient.isAvailable).not.toHaveBeenCalled();
106
+ });
107
+
108
+ it('should pass config to client constructor', async () => {
109
+ MockRestClient.isAvailable.mockResolvedValue(true);
110
+
111
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
112
+ const { client } = await detector.detect(config);
113
+
114
+ expect(MockRestClient).toHaveBeenCalledWith(config);
115
+ expect(client.config).toBe(config);
116
+ });
117
+
118
+ it('should use custom probe timeout', async () => {
119
+ MockRestClient.isAvailable.mockResolvedValue(true);
120
+
121
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient, {
122
+ probeTimeoutMs: 5000,
123
+ });
124
+ await detector.detect(config);
125
+
126
+ expect(MockRestClient.isAvailable).toHaveBeenCalledWith('https://cms.example.com', {
127
+ maxRetries: 0,
128
+ timeoutMs: 5000,
129
+ });
130
+ });
131
+
132
+ it('should record lastProbeTime after detection', async () => {
133
+ MockRestClient.isAvailable.mockResolvedValue(true);
134
+
135
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
136
+ expect(detector.lastProbeTime).toBe(0);
137
+
138
+ await detector.detect(config);
139
+
140
+ expect(detector.lastProbeTime).toBeGreaterThan(0);
141
+ expect(detector.lastProbeTime).toBeLessThanOrEqual(Date.now());
142
+ });
143
+ });
144
+
145
+ // ── reprobe() ────────────────────────────────────────────────────
146
+
147
+ describe('reprobe()', () => {
148
+ it('should detect protocol change from XMDS to REST', async () => {
149
+ MockRestClient.isAvailable.mockResolvedValueOnce(false); // initial detect
150
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
151
+ await detector.detect(config);
152
+ expect(detector.getProtocol()).toBe('xmds');
153
+
154
+ MockRestClient.isAvailable.mockResolvedValueOnce(true); // reprobe
155
+ const { client, protocol, changed } = await detector.reprobe(config);
156
+
157
+ expect(changed).toBe(true);
158
+ expect(protocol).toBe('rest');
159
+ expect(client.type).toBe('rest');
160
+ expect(detector.getProtocol()).toBe('rest');
161
+ });
162
+
163
+ it('should detect protocol change from REST to XMDS', async () => {
164
+ MockRestClient.isAvailable.mockResolvedValueOnce(true); // initial detect
165
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
166
+ await detector.detect(config);
167
+ expect(detector.getProtocol()).toBe('rest');
168
+
169
+ MockRestClient.isAvailable.mockResolvedValueOnce(false); // reprobe
170
+ const { client, protocol, changed } = await detector.reprobe(config);
171
+
172
+ expect(changed).toBe(true);
173
+ expect(protocol).toBe('xmds');
174
+ expect(client.type).toBe('xmds');
175
+ expect(detector.getProtocol()).toBe('xmds');
176
+ });
177
+
178
+ it('should return changed=false when protocol is unchanged', async () => {
179
+ MockRestClient.isAvailable.mockResolvedValueOnce(true); // initial detect
180
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
181
+ await detector.detect(config);
182
+
183
+ MockRestClient.isAvailable.mockResolvedValueOnce(true); // reprobe (same)
184
+ const { client, protocol, changed } = await detector.reprobe(config);
185
+
186
+ expect(changed).toBe(false);
187
+ expect(protocol).toBe('rest');
188
+ expect(client).toBeNull(); // No new client when unchanged
189
+ });
190
+
191
+ it('should update lastProbeTime on reprobe', async () => {
192
+ MockRestClient.isAvailable.mockResolvedValue(true);
193
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
194
+ await detector.detect(config);
195
+ const firstProbe = detector.lastProbeTime;
196
+
197
+ // Small delay to ensure different timestamp
198
+ await new Promise(r => setTimeout(r, 5));
199
+
200
+ await detector.reprobe(config);
201
+ expect(detector.lastProbeTime).toBeGreaterThanOrEqual(firstProbe);
202
+ });
203
+ });
204
+
205
+ // ── getProtocol() ────────────────────────────────────────────────
206
+
207
+ describe('getProtocol()', () => {
208
+ it('should return null before detection', () => {
209
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
210
+ expect(detector.getProtocol()).toBeNull();
211
+ });
212
+
213
+ it('should return detected protocol after detect()', async () => {
214
+ MockRestClient.isAvailable.mockResolvedValue(true);
215
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
216
+ await detector.detect(config);
217
+ expect(detector.getProtocol()).toBe('rest');
218
+ });
219
+ });
220
+
221
+ // ── probe() ──────────────────────────────────────────────────────
222
+
223
+ describe('probe()', () => {
224
+ it('should call RestClient.isAvailable with correct URL and timeout', async () => {
225
+ MockRestClient.isAvailable.mockResolvedValue(true);
226
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
227
+ const result = await detector.probe();
228
+
229
+ expect(result).toBe(true);
230
+ expect(MockRestClient.isAvailable).toHaveBeenCalledWith('https://cms.example.com', {
231
+ maxRetries: 0,
232
+ timeoutMs: 3000,
233
+ });
234
+ });
235
+
236
+ it('should return false when REST is unavailable', async () => {
237
+ MockRestClient.isAvailable.mockResolvedValue(false);
238
+ const detector = new ProtocolDetector('https://cms.example.com', MockRestClient, MockXmdsClient);
239
+ const result = await detector.probe();
240
+
241
+ expect(result).toBe(false);
242
+ });
243
+ });
244
+ });
@@ -513,7 +513,7 @@ export class RestClient {
513
513
 
514
514
  /**
515
515
  * Probe whether the CMS supports API v2.
516
- * GET /api/v2/player/health → { version: 2, status: "ok" }
516
+ * GET ${PLAYER_API}/health → { version: 2, status: "ok" }
517
517
  *
518
518
  * @param {string} cmsUrl - CMS base URL
519
519
  * @param {Object} [retryOptions] - Retry options for fetch
@@ -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';
@@ -5,7 +5,7 @@
5
5
  * with the Player API module deployed.
6
6
  *
7
7
  * Prerequisites:
8
- * - CMS at CMS_URL must have /api/v2/player/* endpoints deployed
8
+ * - CMS at CMS_URL must have ${PLAYER_API}/* endpoints deployed
9
9
  * - A display with the given HARDWARE_KEY must exist and be authorized
10
10
  * - The SERVER_KEY must match the CMS setting
11
11
  *