@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 +177 -23
- package/package.json +2 -2
- package/src/cms-client.js +89 -0
- package/src/cms-client.test.js +51 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +2 -0
- package/src/protocol-detector.js +159 -0
- package/src/protocol-detector.test.js +244 -0
- package/src/rest-client.js +8 -2
- package/src/xmds.rest.integration.test.js +1 -1
package/README.md
CHANGED
|
@@ -1,15 +1,42 @@
|
|
|
1
1
|
# @xiboplayer/xmds
|
|
2
2
|
|
|
3
|
-
**XMDS
|
|
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
|
-
|
|
7
|
+
Unified abstraction over Xibo's two communication protocols:
|
|
8
8
|
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
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
|
|
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://
|
|
27
|
-
|
|
28
|
-
hardwareKey: 'display-
|
|
82
|
+
cmsUrl: 'https://cms.example.com',
|
|
83
|
+
cmsKey: 'server-key',
|
|
84
|
+
hardwareKey: 'display-key',
|
|
85
|
+
displayName: 'Display 1',
|
|
29
86
|
});
|
|
30
87
|
|
|
31
|
-
const
|
|
32
|
-
const files = await client.requiredFiles();
|
|
33
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
|
41
|
-
|
|
42
|
-
| `
|
|
43
|
-
| `
|
|
44
|
-
| `
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
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`
|
|
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.
|
|
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.
|
|
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
|
+
});
|
package/src/rest-client.js
CHANGED
|
@@ -513,7 +513,7 @@ export class RestClient {
|
|
|
513
513
|
|
|
514
514
|
/**
|
|
515
515
|
* Probe whether the CMS supports API v2.
|
|
516
|
-
* GET /
|
|
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
|
|
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
|
|
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
|
*
|