@xiboplayer/core 0.1.0
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/CAMPAIGNS.md +254 -0
- package/README.md +163 -0
- package/TESTING_STATUS.md +281 -0
- package/TEST_STANDARDIZATION_COMPLETE.md +287 -0
- package/docs/ARCHITECTURE.md +714 -0
- package/docs/README.md +92 -0
- package/examples/dayparting-schedule-example.json +190 -0
- package/index.html +262 -0
- package/package.json +53 -0
- package/proxy.js +72 -0
- package/public/manifest.json +22 -0
- package/public/sw.js +218 -0
- package/setup.html +220 -0
- package/src/data-connectors.js +198 -0
- package/src/index.js +4 -0
- package/src/main.js +580 -0
- package/src/player-core.js +1120 -0
- package/src/player-core.test.js +1796 -0
- package/src/state.js +54 -0
- package/src/state.test.js +206 -0
- package/src/test-utils.js +217 -0
- package/src/xmds-test.html +109 -0
- package/src/xmds.test.js +516 -0
- package/vite.config.js +51 -0
- package/vitest.config.js +35 -0
package/public/sw.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Worker for offline cache
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const CACHE_VERSION = 'v1';
|
|
6
|
+
const STATIC_CACHE = `xibo-static-${CACHE_VERSION}`;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Handle Range requests for video/audio seeking
|
|
10
|
+
* Required for HTML5 video element to support seeking/scrubbing
|
|
11
|
+
* For streaming files, downloads chunks on-demand
|
|
12
|
+
*/
|
|
13
|
+
async function handleRangeRequest(cachedResponse, rangeHeader, cacheKey, originalUrl) {
|
|
14
|
+
const blob = await cachedResponse.blob();
|
|
15
|
+
const fileSize = blob.size;
|
|
16
|
+
|
|
17
|
+
// Parse Range header: "bytes=START-END" or "bytes=START-"
|
|
18
|
+
const rangeParts = rangeHeader.replace(/bytes=/, '').split('-');
|
|
19
|
+
const start = parseInt(rangeParts[0], 10);
|
|
20
|
+
const end = rangeParts[1] ? parseInt(rangeParts[1], 10) : fileSize - 1;
|
|
21
|
+
|
|
22
|
+
// Extract requested range from blob
|
|
23
|
+
const rangeBlob = blob.slice(start, end + 1);
|
|
24
|
+
|
|
25
|
+
return new Response(rangeBlob, {
|
|
26
|
+
status: 206, // Partial Content
|
|
27
|
+
statusText: 'Partial Content',
|
|
28
|
+
headers: {
|
|
29
|
+
'Content-Type': cachedResponse.headers.get('Content-Type') || 'video/mp4',
|
|
30
|
+
'Content-Length': rangeBlob.size,
|
|
31
|
+
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
|
32
|
+
'Accept-Ranges': 'bytes',
|
|
33
|
+
'Access-Control-Allow-Origin': '*'
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fetch and cache a chunk from the original URL on-demand
|
|
40
|
+
* Used for streaming large video files
|
|
41
|
+
*/
|
|
42
|
+
async function fetchAndCacheChunk(originalUrl, rangeHeader, cacheKey, cache) {
|
|
43
|
+
console.log('[SW] Fetching chunk on-demand:', rangeHeader);
|
|
44
|
+
|
|
45
|
+
const response = await fetch(originalUrl, {
|
|
46
|
+
headers: { 'Range': rangeHeader }
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!response.ok && response.status !== 206) {
|
|
50
|
+
throw new Error(`Failed to fetch chunk: ${response.status}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Cache this chunk for future requests
|
|
54
|
+
const responseClone = response.clone();
|
|
55
|
+
await cache.put(cacheKey + '#' + rangeHeader, responseClone);
|
|
56
|
+
|
|
57
|
+
return response;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Files to cache on install (with /player/ prefix)
|
|
61
|
+
const STATIC_FILES = [
|
|
62
|
+
'/player/',
|
|
63
|
+
'/player/index.html',
|
|
64
|
+
'/player/setup.html',
|
|
65
|
+
'/player/manifest.json'
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
// Install event - cache static files
|
|
69
|
+
self.addEventListener('install', (event) => {
|
|
70
|
+
console.log('[SW] Installing...');
|
|
71
|
+
event.waitUntil(
|
|
72
|
+
caches.open(STATIC_CACHE).then((cache) => {
|
|
73
|
+
console.log('[SW] Caching static files');
|
|
74
|
+
return cache.addAll(STATIC_FILES);
|
|
75
|
+
})
|
|
76
|
+
);
|
|
77
|
+
self.skipWaiting();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Activate event - clean up old caches
|
|
81
|
+
self.addEventListener('activate', (event) => {
|
|
82
|
+
console.log('[SW] Activating...');
|
|
83
|
+
event.waitUntil(
|
|
84
|
+
caches.keys().then((cacheNames) => {
|
|
85
|
+
return Promise.all(
|
|
86
|
+
cacheNames
|
|
87
|
+
.filter((name) => name.startsWith('xibo-') && name !== STATIC_CACHE && name !== 'xibo-media-v1')
|
|
88
|
+
.map((name) => {
|
|
89
|
+
console.log('[SW] Deleting old cache:', name);
|
|
90
|
+
return caches.delete(name);
|
|
91
|
+
})
|
|
92
|
+
);
|
|
93
|
+
})
|
|
94
|
+
);
|
|
95
|
+
self.clients.claim();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Fetch event - serve from cache, fallback to network
|
|
99
|
+
self.addEventListener('fetch', (event) => {
|
|
100
|
+
const url = new URL(event.request.url);
|
|
101
|
+
|
|
102
|
+
// IMPORTANT: Let XMDS downloads bypass Service Worker
|
|
103
|
+
// Large video files (2GB+) cause timeouts if intercepted
|
|
104
|
+
// The cache.js module handles downloads directly
|
|
105
|
+
if (url.pathname.includes('xmds.php') && url.searchParams.has('file')) {
|
|
106
|
+
// Don't intercept - let it go directly to network
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Handle widget HTML requests (/player/cache/widget/*)
|
|
111
|
+
if (url.pathname.startsWith('/player/cache/widget/')) {
|
|
112
|
+
console.log('[SW] Widget HTML request:', url.pathname);
|
|
113
|
+
// Strip /player/ prefix to match cached keys
|
|
114
|
+
const cacheKey = url.pathname.replace('/player', '');
|
|
115
|
+
const cacheUrl = new URL(cacheKey, url.origin);
|
|
116
|
+
event.respondWith(
|
|
117
|
+
caches.open('xibo-media-v1').then((cache) => {
|
|
118
|
+
return cache.match(cacheUrl).then((response) => {
|
|
119
|
+
if (response) {
|
|
120
|
+
console.log('[SW] Serving widget HTML from cache:', cacheKey);
|
|
121
|
+
return new Response(response.body, {
|
|
122
|
+
headers: {
|
|
123
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
124
|
+
'Access-Control-Allow-Origin': '*',
|
|
125
|
+
'Cache-Control': 'public, max-age=31536000'
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
console.warn('[SW] Widget HTML not found in cache:', cacheKey);
|
|
130
|
+
return new Response('<!DOCTYPE html><html><body>Widget not found</body></html>', {
|
|
131
|
+
status: 404,
|
|
132
|
+
headers: { 'Content-Type': 'text/html' }
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
})
|
|
136
|
+
);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Handle cache URLs (/player/cache/*)
|
|
141
|
+
if (url.pathname.startsWith('/player/cache/')) {
|
|
142
|
+
// Strip /player/ prefix to match cached keys
|
|
143
|
+
const cacheKey = url.pathname.replace('/player', '');
|
|
144
|
+
console.log('[SW] Request for:', url.pathname, '→ Cache key:', cacheKey);
|
|
145
|
+
|
|
146
|
+
event.respondWith(
|
|
147
|
+
caches.open('xibo-media-v1').then((cache) => {
|
|
148
|
+
return cache.match(cacheKey).then(async (response) => {
|
|
149
|
+
if (response) {
|
|
150
|
+
console.log('[SW] Serving from cache:', cacheKey);
|
|
151
|
+
|
|
152
|
+
// Handle Range requests for video/audio seeking
|
|
153
|
+
const rangeHeader = event.request.headers.get('Range');
|
|
154
|
+
if (rangeHeader) {
|
|
155
|
+
return handleRangeRequest(response, rangeHeader, cacheKey, event.request.url);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Regular response
|
|
159
|
+
return new Response(response.body, {
|
|
160
|
+
status: 200,
|
|
161
|
+
statusText: 'OK',
|
|
162
|
+
headers: {
|
|
163
|
+
'Content-Type': response.headers.get('Content-Type') || 'application/octet-stream',
|
|
164
|
+
'Content-Length': response.headers.get('Content-Length') || '',
|
|
165
|
+
'Accept-Ranges': 'bytes',
|
|
166
|
+
'Access-Control-Allow-Origin': '*'
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Cache miss - might be background download in progress
|
|
172
|
+
// For media files, show a loading message instead of 404
|
|
173
|
+
console.warn('[SW] Cache miss for:', cacheKey, '(might be downloading in background)');
|
|
174
|
+
|
|
175
|
+
// Return a placeholder for videos being downloaded
|
|
176
|
+
if (cacheKey.includes('.mp4') || cacheKey.includes('.mov') || cacheKey.includes('.avi')) {
|
|
177
|
+
return new Response('Downloading video in background...', {
|
|
178
|
+
status: 202,
|
|
179
|
+
statusText: 'Accepted',
|
|
180
|
+
headers: { 'Content-Type': 'text/plain' }
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return new Response('Not found', { status: 404 });
|
|
185
|
+
});
|
|
186
|
+
}).catch(err => {
|
|
187
|
+
console.error('[SW] Error serving:', cacheKey, err);
|
|
188
|
+
return new Response('Error: ' + err.message, { status: 500 });
|
|
189
|
+
})
|
|
190
|
+
);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// For other requests, try cache first, then network
|
|
195
|
+
event.respondWith(
|
|
196
|
+
caches.match(event.request).then((response) => {
|
|
197
|
+
if (response) {
|
|
198
|
+
return response;
|
|
199
|
+
}
|
|
200
|
+
return fetch(event.request).then((networkResponse) => {
|
|
201
|
+
// Cache successful GET responses only (can't cache POST)
|
|
202
|
+
if (networkResponse.ok && event.request.method === 'GET') {
|
|
203
|
+
const responseClone = networkResponse.clone();
|
|
204
|
+
caches.open(STATIC_CACHE).then((cache) => {
|
|
205
|
+
cache.put(event.request, responseClone);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return networkResponse;
|
|
209
|
+
});
|
|
210
|
+
}).catch(() => {
|
|
211
|
+
// Network failed and not in cache
|
|
212
|
+
if (event.request.destination === 'document') {
|
|
213
|
+
return caches.match('/player/index.html');
|
|
214
|
+
}
|
|
215
|
+
return new Response('Offline', { status: 503 });
|
|
216
|
+
})
|
|
217
|
+
);
|
|
218
|
+
});
|
package/setup.html
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Xibo Player Setup</title>
|
|
7
|
+
<style>
|
|
8
|
+
* {
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
box-sizing: border-box;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
body {
|
|
15
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
16
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
17
|
+
min-height: 100vh;
|
|
18
|
+
display: flex;
|
|
19
|
+
align-items: center;
|
|
20
|
+
justify-content: center;
|
|
21
|
+
padding: 20px;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.container {
|
|
25
|
+
background: white;
|
|
26
|
+
border-radius: 12px;
|
|
27
|
+
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
|
28
|
+
padding: 40px;
|
|
29
|
+
max-width: 500px;
|
|
30
|
+
width: 100%;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
h1 {
|
|
34
|
+
margin-bottom: 10px;
|
|
35
|
+
color: #333;
|
|
36
|
+
font-size: 28px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
p {
|
|
40
|
+
color: #666;
|
|
41
|
+
margin-bottom: 30px;
|
|
42
|
+
line-height: 1.5;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.form-group {
|
|
46
|
+
margin-bottom: 20px;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
label {
|
|
50
|
+
display: block;
|
|
51
|
+
margin-bottom: 8px;
|
|
52
|
+
color: #333;
|
|
53
|
+
font-weight: 500;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
input {
|
|
57
|
+
width: 100%;
|
|
58
|
+
padding: 12px;
|
|
59
|
+
border: 2px solid #e1e8ed;
|
|
60
|
+
border-radius: 6px;
|
|
61
|
+
font-size: 16px;
|
|
62
|
+
transition: border-color 0.3s;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
input:focus {
|
|
66
|
+
outline: none;
|
|
67
|
+
border-color: #667eea;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
button {
|
|
71
|
+
width: 100%;
|
|
72
|
+
padding: 14px;
|
|
73
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
74
|
+
color: white;
|
|
75
|
+
border: none;
|
|
76
|
+
border-radius: 6px;
|
|
77
|
+
font-size: 16px;
|
|
78
|
+
font-weight: 600;
|
|
79
|
+
cursor: pointer;
|
|
80
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
button:hover {
|
|
84
|
+
transform: translateY(-2px);
|
|
85
|
+
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
button:active {
|
|
89
|
+
transform: translateY(0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.info {
|
|
93
|
+
margin-top: 20px;
|
|
94
|
+
padding: 15px;
|
|
95
|
+
background: #f8f9fa;
|
|
96
|
+
border-radius: 6px;
|
|
97
|
+
font-size: 14px;
|
|
98
|
+
color: #666;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.info code {
|
|
102
|
+
background: white;
|
|
103
|
+
padding: 2px 6px;
|
|
104
|
+
border-radius: 3px;
|
|
105
|
+
font-family: monospace;
|
|
106
|
+
color: #e83e8c;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.error {
|
|
110
|
+
margin-top: 15px;
|
|
111
|
+
padding: 12px;
|
|
112
|
+
background: #fee;
|
|
113
|
+
border: 1px solid #fcc;
|
|
114
|
+
border-radius: 6px;
|
|
115
|
+
color: #c33;
|
|
116
|
+
display: none;
|
|
117
|
+
}
|
|
118
|
+
</style>
|
|
119
|
+
</head>
|
|
120
|
+
<body>
|
|
121
|
+
<div class="container">
|
|
122
|
+
<h1>Xibo Player Setup</h1>
|
|
123
|
+
<p>Configure your connection to the Xibo CMS.</p>
|
|
124
|
+
|
|
125
|
+
<form id="setup-form">
|
|
126
|
+
<div class="form-group">
|
|
127
|
+
<label for="cms-address">CMS Address</label>
|
|
128
|
+
<input
|
|
129
|
+
type="url"
|
|
130
|
+
id="cms-address"
|
|
131
|
+
placeholder="https://cms.example.com"
|
|
132
|
+
required
|
|
133
|
+
>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div class="form-group">
|
|
137
|
+
<label for="cms-key">CMS Key</label>
|
|
138
|
+
<input
|
|
139
|
+
type="text"
|
|
140
|
+
id="cms-key"
|
|
141
|
+
placeholder="Your CMS secret key"
|
|
142
|
+
required
|
|
143
|
+
>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div class="form-group">
|
|
147
|
+
<label for="display-name">Display Name</label>
|
|
148
|
+
<input
|
|
149
|
+
type="text"
|
|
150
|
+
id="display-name"
|
|
151
|
+
placeholder="My Display"
|
|
152
|
+
required
|
|
153
|
+
>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<button type="submit">Connect</button>
|
|
157
|
+
</form>
|
|
158
|
+
|
|
159
|
+
<div id="error" class="error"></div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<script type="module">
|
|
163
|
+
import { config } from './src/config.js';
|
|
164
|
+
import { XmdsClient } from './src/xmds.js';
|
|
165
|
+
|
|
166
|
+
const form = document.getElementById('setup-form');
|
|
167
|
+
const errorEl = document.getElementById('error');
|
|
168
|
+
|
|
169
|
+
// Pre-fill if already configured
|
|
170
|
+
if (config.isConfigured()) {
|
|
171
|
+
document.getElementById('cms-address').value = config.cmsAddress;
|
|
172
|
+
document.getElementById('cms-key').value = config.cmsKey;
|
|
173
|
+
document.getElementById('display-name').value = config.displayName;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
form.addEventListener('submit', async (e) => {
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
errorEl.style.display = 'none';
|
|
179
|
+
|
|
180
|
+
const cmsAddress = document.getElementById('cms-address').value.trim().replace(/\/$/, '');
|
|
181
|
+
const cmsKey = document.getElementById('cms-key').value.trim();
|
|
182
|
+
const displayName = document.getElementById('display-name').value.trim();
|
|
183
|
+
|
|
184
|
+
// Save config
|
|
185
|
+
config.cmsAddress = cmsAddress;
|
|
186
|
+
config.cmsKey = cmsKey;
|
|
187
|
+
config.displayName = displayName;
|
|
188
|
+
|
|
189
|
+
// Test connection
|
|
190
|
+
try {
|
|
191
|
+
const button = form.querySelector('button');
|
|
192
|
+
button.textContent = 'Connecting...';
|
|
193
|
+
button.disabled = true;
|
|
194
|
+
|
|
195
|
+
const xmds = new XmdsClient(config);
|
|
196
|
+
const result = await xmds.registerDisplay();
|
|
197
|
+
|
|
198
|
+
if (result.code === 'READY') {
|
|
199
|
+
// Success!
|
|
200
|
+
window.location.href = '/player/';
|
|
201
|
+
} else {
|
|
202
|
+
// Not yet authorized
|
|
203
|
+
errorEl.textContent = `Display registered but not authorized yet. Please authorize "${displayName}" in your CMS, then refresh this page.`;
|
|
204
|
+
errorEl.style.display = 'block';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
button.textContent = 'Connect';
|
|
208
|
+
button.disabled = false;
|
|
209
|
+
} catch (error) {
|
|
210
|
+
errorEl.textContent = `Connection failed: ${error.message}`;
|
|
211
|
+
errorEl.style.display = 'block';
|
|
212
|
+
|
|
213
|
+
const button = form.querySelector('button');
|
|
214
|
+
button.textContent = 'Connect';
|
|
215
|
+
button.disabled = false;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
</script>
|
|
219
|
+
</body>
|
|
220
|
+
</html>
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataConnectorManager - Manages real-time data connectors from CMS
|
|
3
|
+
*
|
|
4
|
+
* Data connectors allow widgets to receive real-time data from CMS-configured
|
|
5
|
+
* data sources. The CMS sends data connector configuration via the schedule XML,
|
|
6
|
+
* and this manager periodically polls the data source URLs, stores the data,
|
|
7
|
+
* and emits events so the IC /realtime route can serve it to widgets.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* const manager = new DataConnectorManager();
|
|
11
|
+
* manager.setConnectors(schedule.dataConnectors);
|
|
12
|
+
* manager.startPolling();
|
|
13
|
+
*
|
|
14
|
+
* // Get data for a widget
|
|
15
|
+
* const data = manager.getData('weather_data');
|
|
16
|
+
*
|
|
17
|
+
* // Listen for updates
|
|
18
|
+
* manager.on('data-updated', (dataKey, data) => { ... });
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { EventEmitter, createLogger, fetchWithRetry } from '@xiboplayer/utils';
|
|
22
|
+
|
|
23
|
+
const log = createLogger('DataConnector');
|
|
24
|
+
|
|
25
|
+
export class DataConnectorManager extends EventEmitter {
|
|
26
|
+
constructor() {
|
|
27
|
+
super();
|
|
28
|
+
|
|
29
|
+
// dataKey -> { config, data, timer, lastFetch }
|
|
30
|
+
this.connectors = new Map();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Set active connectors from schedule
|
|
35
|
+
* Stops any existing polling and reconfigures with new connector list.
|
|
36
|
+
* @param {Array} connectors - Array of connector config objects from schedule XML
|
|
37
|
+
* Each: { id, dataConnectorId, dataKey, url, updateInterval }
|
|
38
|
+
*/
|
|
39
|
+
setConnectors(connectors) {
|
|
40
|
+
// Stop existing polling before reconfiguring
|
|
41
|
+
this.stopPolling();
|
|
42
|
+
|
|
43
|
+
// Clear previous connectors
|
|
44
|
+
this.connectors.clear();
|
|
45
|
+
|
|
46
|
+
if (!connectors || connectors.length === 0) {
|
|
47
|
+
log.debug('No data connectors configured');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const connector of connectors) {
|
|
52
|
+
if (!connector.dataKey || !connector.url) {
|
|
53
|
+
log.warn('Skipping data connector with missing dataKey or url:', connector);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
this.connectors.set(connector.dataKey, {
|
|
58
|
+
config: connector,
|
|
59
|
+
data: null,
|
|
60
|
+
timer: null,
|
|
61
|
+
lastFetch: null
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
log.info(`Registered data connector: ${connector.dataKey} (interval: ${connector.updateInterval}s)`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
log.info(`${this.connectors.size} data connector(s) configured`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Start polling for all active connectors
|
|
72
|
+
* Performs an initial fetch immediately, then sets up periodic polling.
|
|
73
|
+
*/
|
|
74
|
+
startPolling() {
|
|
75
|
+
for (const [dataKey, entry] of this.connectors.entries()) {
|
|
76
|
+
const { config } = entry;
|
|
77
|
+
const intervalMs = (config.updateInterval || 300) * 1000;
|
|
78
|
+
|
|
79
|
+
// Fetch immediately on start
|
|
80
|
+
this.fetchData(entry).catch(err => {
|
|
81
|
+
log.error(`Initial fetch failed for ${dataKey}:`, err);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Set up periodic polling
|
|
85
|
+
entry.timer = setInterval(() => {
|
|
86
|
+
this.fetchData(entry).catch(err => {
|
|
87
|
+
log.error(`Polling fetch failed for ${dataKey}:`, err);
|
|
88
|
+
});
|
|
89
|
+
}, intervalMs);
|
|
90
|
+
|
|
91
|
+
log.debug(`Started polling for ${dataKey} every ${config.updateInterval}s`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Stop all polling timers
|
|
97
|
+
*/
|
|
98
|
+
stopPolling() {
|
|
99
|
+
for (const [dataKey, entry] of this.connectors.entries()) {
|
|
100
|
+
if (entry.timer) {
|
|
101
|
+
clearInterval(entry.timer);
|
|
102
|
+
entry.timer = null;
|
|
103
|
+
log.debug(`Stopped polling for ${dataKey}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get current data for a dataKey
|
|
110
|
+
* @param {string} dataKey - The data key to look up
|
|
111
|
+
* @returns {Object|null} The stored data, or null if not available
|
|
112
|
+
*/
|
|
113
|
+
getData(dataKey) {
|
|
114
|
+
const entry = this.connectors.get(dataKey);
|
|
115
|
+
if (!entry) {
|
|
116
|
+
log.debug(`No data connector found for key: ${dataKey}`);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
return entry.data;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get all data keys that have data available
|
|
124
|
+
* @returns {string[]} Array of data keys with data
|
|
125
|
+
*/
|
|
126
|
+
getAvailableKeys() {
|
|
127
|
+
const keys = [];
|
|
128
|
+
for (const [dataKey, entry] of this.connectors.entries()) {
|
|
129
|
+
if (entry.data !== null) {
|
|
130
|
+
keys.push(dataKey);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return keys;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Internal: fetch data from CMS data source
|
|
138
|
+
* @param {Object} entry - Connector entry from this.connectors
|
|
139
|
+
*/
|
|
140
|
+
async fetchData(entry) {
|
|
141
|
+
const { config } = entry;
|
|
142
|
+
const { dataKey, url } = config;
|
|
143
|
+
|
|
144
|
+
log.debug(`Fetching data for ${dataKey}: ${url}`);
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const response = await fetchWithRetry(url, {
|
|
148
|
+
method: 'GET',
|
|
149
|
+
headers: {
|
|
150
|
+
'Accept': 'application/json'
|
|
151
|
+
}
|
|
152
|
+
}, { maxRetries: 2, baseDelayMs: 2000 });
|
|
153
|
+
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
log.warn(`Data connector ${dataKey} returned ${response.status}: ${response.statusText}`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const contentType = response.headers.get('Content-Type') || '';
|
|
160
|
+
let data;
|
|
161
|
+
|
|
162
|
+
if (contentType.includes('application/json')) {
|
|
163
|
+
data = await response.json();
|
|
164
|
+
} else {
|
|
165
|
+
// Store as raw text if not JSON
|
|
166
|
+
data = await response.text();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const previousData = entry.data;
|
|
170
|
+
entry.data = data;
|
|
171
|
+
entry.lastFetch = Date.now();
|
|
172
|
+
|
|
173
|
+
log.debug(`Data updated for ${dataKey} (fetched at ${new Date(entry.lastFetch).toISOString()})`);
|
|
174
|
+
|
|
175
|
+
// Emit event for listeners (IC route, platform layer)
|
|
176
|
+
this.emit('data-updated', dataKey, data);
|
|
177
|
+
|
|
178
|
+
// Emit a specific event if data actually changed
|
|
179
|
+
if (JSON.stringify(previousData) !== JSON.stringify(data)) {
|
|
180
|
+
this.emit('data-changed', dataKey, data);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
} catch (error) {
|
|
184
|
+
log.error(`Failed to fetch data for ${dataKey}:`, error);
|
|
185
|
+
this.emit('fetch-error', dataKey, error);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Cleanup - stop all polling and remove listeners
|
|
191
|
+
*/
|
|
192
|
+
cleanup() {
|
|
193
|
+
this.stopPolling();
|
|
194
|
+
this.connectors.clear();
|
|
195
|
+
this.removeAllListeners();
|
|
196
|
+
log.debug('DataConnectorManager cleaned up');
|
|
197
|
+
}
|
|
198
|
+
}
|
package/src/index.js
ADDED