@xiboplayer/cache 0.5.8 → 0.5.10
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 +22 -13
- package/docs/CACHE_PROXY_ARCHITECTURE.md +165 -368
- package/package.json +7 -4
- package/src/cache-analyzer.js +9 -5
- package/src/cache-analyzer.test.js +6 -6
- package/src/cache-proxy.test.js +239 -237
- package/src/cache.js +3 -6
- package/src/cache.test.js +2 -30
- package/src/download-client.js +222 -0
- package/src/download-manager.js +48 -5
- package/src/index.js +3 -2
- package/src/store-client.js +114 -0
- package/src/widget-html.js +70 -54
- package/src/widget-html.test.js +71 -62
- package/src/cache-proxy.js +0 -532
package/src/cache.js
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CacheManager - Dependant tracking and cache lifecycle
|
|
3
3
|
*
|
|
4
|
-
* After the
|
|
5
|
-
* the
|
|
4
|
+
* After the storage unification, all downloads and file retrieval go through
|
|
5
|
+
* the proxy's ContentStore (via StoreClient/DownloadClient). This class retains:
|
|
6
6
|
* - Dependant tracking (which layouts reference which media)
|
|
7
7
|
* - Cache key generation
|
|
8
|
-
* - Full cache clearing
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
10
|
import { createLogger } from '@xiboplayer/utils';
|
|
12
11
|
|
|
13
12
|
const log = createLogger('Cache');
|
|
14
|
-
const CACHE_NAME = 'xibo-media-v1';
|
|
15
13
|
|
|
16
14
|
// Dynamic base path for multi-variant deployment (pwa, pwa-xmds, pwa-xlr)
|
|
17
15
|
const BASE = (typeof window !== 'undefined')
|
|
@@ -80,10 +78,9 @@ export class CacheManager {
|
|
|
80
78
|
}
|
|
81
79
|
|
|
82
80
|
/**
|
|
83
|
-
* Clear all cached files
|
|
81
|
+
* Clear all cached files via proxy
|
|
84
82
|
*/
|
|
85
83
|
async clearAll() {
|
|
86
|
-
await caches.delete(CACHE_NAME);
|
|
87
84
|
this.dependants.clear();
|
|
88
85
|
}
|
|
89
86
|
}
|
package/src/cache.test.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Cache Manager Tests
|
|
3
3
|
*
|
|
4
4
|
* Tests for the slimmed-down CacheManager: dependant tracking, getCacheKey,
|
|
5
|
-
* and clearAll.
|
|
5
|
+
* and clearAll. Storage is handled by ContentStore via proxy REST endpoints.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
@@ -10,36 +10,8 @@ import { CacheManager } from './cache.js';
|
|
|
10
10
|
|
|
11
11
|
describe('CacheManager', () => {
|
|
12
12
|
let manager;
|
|
13
|
-
let mockCache;
|
|
14
13
|
|
|
15
14
|
beforeEach(async () => {
|
|
16
|
-
// Mock Cache API
|
|
17
|
-
mockCache = {
|
|
18
|
-
_storage: new Map(),
|
|
19
|
-
async match(key) {
|
|
20
|
-
const keyStr = typeof key === 'string' ? key : key.toString();
|
|
21
|
-
const entry = this._storage.get(keyStr);
|
|
22
|
-
if (!entry) return undefined;
|
|
23
|
-
return new Response(entry.body, { headers: entry.headers });
|
|
24
|
-
},
|
|
25
|
-
async put(key, response) {
|
|
26
|
-
const keyStr = typeof key === 'string' ? key : key.toString();
|
|
27
|
-
const bodyText = await response.text();
|
|
28
|
-
const headers = {};
|
|
29
|
-
response.headers.forEach((value, name) => { headers[name] = value; });
|
|
30
|
-
this._storage.set(keyStr, { body: bodyText, headers });
|
|
31
|
-
},
|
|
32
|
-
async delete(key) {
|
|
33
|
-
const keyStr = typeof key === 'string' ? key : key.toString();
|
|
34
|
-
return this._storage.delete(keyStr);
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
global.caches = {
|
|
39
|
-
async open() { return mockCache; },
|
|
40
|
-
async delete() { mockCache._storage.clear(); }
|
|
41
|
-
};
|
|
42
|
-
|
|
43
15
|
manager = new CacheManager();
|
|
44
16
|
});
|
|
45
17
|
|
|
@@ -155,7 +127,7 @@ describe('CacheManager', () => {
|
|
|
155
127
|
});
|
|
156
128
|
|
|
157
129
|
describe('clearAll()', () => {
|
|
158
|
-
it('should clear
|
|
130
|
+
it('should clear dependants', async () => {
|
|
159
131
|
manager.addDependant('media1', 'layout1');
|
|
160
132
|
manager.addDependant('media2', 'layout2');
|
|
161
133
|
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DownloadClient — Service Worker postMessage interface for download orchestration
|
|
3
|
+
*
|
|
4
|
+
* Communicates with the Service Worker via postMessage for:
|
|
5
|
+
* - Background file downloads (DOWNLOAD_FILES)
|
|
6
|
+
* - Download prioritization (PRIORITIZE_DOWNLOAD, PRIORITIZE_LAYOUT_FILES)
|
|
7
|
+
* - Progress reporting (GET_DOWNLOAD_PROGRESS)
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* const downloads = new DownloadClient();
|
|
11
|
+
* await downloads.init(); // Waits for SW to be ready
|
|
12
|
+
* await downloads.download(files);
|
|
13
|
+
* downloads.prioritize('media', '123');
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createLogger } from '@xiboplayer/utils';
|
|
17
|
+
|
|
18
|
+
const log = createLogger('DownloadClient');
|
|
19
|
+
|
|
20
|
+
export class DownloadClient {
|
|
21
|
+
constructor() {
|
|
22
|
+
this.controller = null;
|
|
23
|
+
this.fetchReady = false;
|
|
24
|
+
this._fetchReadyPromise = null;
|
|
25
|
+
this._fetchReadyResolve = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Initialize — waits for Service Worker to be ready and controlling the page.
|
|
30
|
+
*/
|
|
31
|
+
async init() {
|
|
32
|
+
if (!('serviceWorker' in navigator)) {
|
|
33
|
+
throw new Error('Service Worker not supported — PWA requires Service Worker');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Guard against double-initialization (would add duplicate listeners)
|
|
37
|
+
if (this._swReadyHandler) return;
|
|
38
|
+
|
|
39
|
+
// Create promise for fetch readiness (resolved when SW sends SW_READY)
|
|
40
|
+
this._fetchReadyPromise = new Promise(resolve => {
|
|
41
|
+
this._fetchReadyResolve = resolve;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Listen for SW_READY message (store handler for cleanup)
|
|
45
|
+
this._swReadyHandler = (event) => {
|
|
46
|
+
if (event.data?.type === 'SW_READY') {
|
|
47
|
+
log.info('Received SW_READY signal — fetch handler is ready');
|
|
48
|
+
this.fetchReady = true;
|
|
49
|
+
this._fetchReadyResolve();
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
navigator.serviceWorker.addEventListener('message', this._swReadyHandler);
|
|
53
|
+
|
|
54
|
+
const registration = await navigator.serviceWorker.getRegistration();
|
|
55
|
+
|
|
56
|
+
// FAST PATH: Active SW, no updates pending
|
|
57
|
+
if (registration && registration.active && !registration.installing && !registration.waiting) {
|
|
58
|
+
log.info('Active Service Worker found (no updates pending)');
|
|
59
|
+
this.controller = navigator.serviceWorker.controller || registration.active;
|
|
60
|
+
|
|
61
|
+
// If not controlling yet, give it a moment to claim page
|
|
62
|
+
if (!navigator.serviceWorker.controller) {
|
|
63
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
this.controller.postMessage({ type: 'PING' });
|
|
67
|
+
log.info('DownloadClient initialized, waiting for fetch readiness...');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// If there's a new SW installing/waiting, wait for it
|
|
72
|
+
if (registration && (registration.installing || registration.waiting)) {
|
|
73
|
+
log.info('New Service Worker detected, waiting for it to activate...');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// SLOW PATH: No active SW, wait for registration (fresh install)
|
|
77
|
+
log.info('No active Service Worker, waiting for registration...');
|
|
78
|
+
|
|
79
|
+
const swReady = navigator.serviceWorker.ready;
|
|
80
|
+
const timeout = new Promise((_, reject) =>
|
|
81
|
+
setTimeout(() => reject(new Error('Service Worker ready timeout after 10s')), 10000)
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await Promise.race([swReady, timeout]);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
log.error('Service Worker wait failed:', error);
|
|
88
|
+
throw new Error('Service Worker not ready — please reload page');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Wait for SW to claim page
|
|
92
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
93
|
+
|
|
94
|
+
this.controller = navigator.serviceWorker.controller;
|
|
95
|
+
if (!this.controller) {
|
|
96
|
+
const reg = await navigator.serviceWorker.getRegistration();
|
|
97
|
+
this.controller = reg?.active;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (this.controller) {
|
|
101
|
+
this.controller.postMessage({ type: 'PING' });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
log.info('DownloadClient initialized (slow path)');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Wait for fetch readiness before operations that need it.
|
|
109
|
+
*/
|
|
110
|
+
async _ensureReady() {
|
|
111
|
+
if (!this.fetchReady && this._fetchReadyPromise) {
|
|
112
|
+
await this._fetchReadyPromise;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Request file downloads from Service Worker (non-blocking).
|
|
118
|
+
* @param {Object|Array} payload - { layoutOrder, files, layoutDependants } or flat Array
|
|
119
|
+
* @returns {Promise<void>}
|
|
120
|
+
*/
|
|
121
|
+
async download(payload) {
|
|
122
|
+
if (!this.controller) {
|
|
123
|
+
throw new Error('Service Worker not available');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const data = Array.isArray(payload)
|
|
127
|
+
? { files: payload }
|
|
128
|
+
: payload;
|
|
129
|
+
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const messageChannel = new MessageChannel();
|
|
132
|
+
|
|
133
|
+
messageChannel.port1.onmessage = (event) => {
|
|
134
|
+
const { success, error, enqueuedCount, activeCount, queuedCount } = event.data;
|
|
135
|
+
if (success) {
|
|
136
|
+
log.info('Download request acknowledged:', enqueuedCount, 'files');
|
|
137
|
+
log.info('Queue state:', activeCount, 'active,', queuedCount, 'queued');
|
|
138
|
+
resolve();
|
|
139
|
+
} else {
|
|
140
|
+
reject(new Error(error || 'Service Worker download failed'));
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
this.controller.postMessage(
|
|
145
|
+
{ type: 'DOWNLOAD_FILES', data },
|
|
146
|
+
[messageChannel.port2]
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Prioritize downloading a specific file (move to front of queue).
|
|
153
|
+
* @param {string} fileType - 'media' or 'layout'
|
|
154
|
+
* @param {string} fileId - File ID
|
|
155
|
+
*/
|
|
156
|
+
async prioritize(fileType, fileId) {
|
|
157
|
+
if (!this.controller) return;
|
|
158
|
+
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
const messageChannel = new MessageChannel();
|
|
161
|
+
messageChannel.port1.onmessage = (event) => resolve(event.data);
|
|
162
|
+
this.controller.postMessage(
|
|
163
|
+
{ type: 'PRIORITIZE_DOWNLOAD', data: { fileType, fileId } },
|
|
164
|
+
[messageChannel.port2]
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Prioritize layout files — reorder queue and hold other downloads until done.
|
|
171
|
+
* @param {string[]} mediaIds - Media IDs needed by the current layout
|
|
172
|
+
*/
|
|
173
|
+
async prioritizeLayout(mediaIds) {
|
|
174
|
+
if (!this.controller) return;
|
|
175
|
+
this.controller.postMessage({ type: 'PRIORITIZE_LAYOUT_FILES', data: { mediaIds } });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get download progress from Service Worker.
|
|
180
|
+
* @returns {Promise<Object>} Progress info for all active downloads
|
|
181
|
+
*/
|
|
182
|
+
async getProgress() {
|
|
183
|
+
if (!this.controller) return {};
|
|
184
|
+
|
|
185
|
+
return new Promise((resolve) => {
|
|
186
|
+
const channel = new MessageChannel();
|
|
187
|
+
let settled = false;
|
|
188
|
+
|
|
189
|
+
const timer = setTimeout(() => {
|
|
190
|
+
if (!settled) {
|
|
191
|
+
settled = true;
|
|
192
|
+
channel.port1.onmessage = null;
|
|
193
|
+
resolve({});
|
|
194
|
+
}
|
|
195
|
+
}, 1000);
|
|
196
|
+
|
|
197
|
+
channel.port1.onmessage = (event) => {
|
|
198
|
+
if (!settled) {
|
|
199
|
+
settled = true;
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
const { success, progress } = event.data;
|
|
202
|
+
resolve(success ? progress : {});
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
this.controller.postMessage(
|
|
207
|
+
{ type: 'GET_DOWNLOAD_PROGRESS' },
|
|
208
|
+
[channel.port2]
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Remove event listeners added during init().
|
|
215
|
+
*/
|
|
216
|
+
cleanup() {
|
|
217
|
+
if (this._swReadyHandler && 'serviceWorker' in navigator) {
|
|
218
|
+
navigator.serviceWorker.removeEventListener('message', this._swReadyHandler);
|
|
219
|
+
this._swReadyHandler = null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
package/src/download-manager.js
CHANGED
|
@@ -40,6 +40,7 @@ const RETRY_DELAY_MS = 500; // Fast: 500ms, 1s, 1.5s → total ~3s
|
|
|
40
40
|
const GETDATA_MAX_RETRIES = 4;
|
|
41
41
|
const GETDATA_RETRY_DELAYS = [15_000, 30_000, 60_000, 120_000]; // 15s, 30s, 60s, 120s
|
|
42
42
|
const GETDATA_REENQUEUE_DELAY_MS = 60_000; // Re-add to queue after 60s if all retries fail
|
|
43
|
+
const GETDATA_MAX_REENQUEUES = 5; // Max times a getData can be re-enqueued before permanent failure
|
|
43
44
|
const URGENT_CONCURRENCY = 2; // Slots when urgent chunk is active (bandwidth focus)
|
|
44
45
|
const FETCH_TIMEOUT_MS = 600_000; // 10 minutes — 100MB chunk at ~2 Mbps
|
|
45
46
|
const HEAD_TIMEOUT_MS = 15_000; // 15 seconds for HEAD requests
|
|
@@ -108,7 +109,7 @@ export function isUrlExpired(url, graceSeconds = 30) {
|
|
|
108
109
|
* the proxy server (Chromium kiosk or Electron).
|
|
109
110
|
* Detection: SW/window on localhost (any port) = proxy mode.
|
|
110
111
|
*/
|
|
111
|
-
export function
|
|
112
|
+
export function toProxyUrl(url) {
|
|
112
113
|
if (!url.startsWith('http')) return url;
|
|
113
114
|
const loc = typeof self !== 'undefined' ? self.location : undefined;
|
|
114
115
|
if (!loc || loc.hostname !== 'localhost') return url;
|
|
@@ -142,7 +143,24 @@ export class DownloadTask {
|
|
|
142
143
|
if (isUrlExpired(url)) {
|
|
143
144
|
throw new Error(`URL expired for ${this.fileInfo.type}/${this.fileInfo.id} — waiting for fresh URL from next collection cycle`);
|
|
144
145
|
}
|
|
145
|
-
|
|
146
|
+
let proxyUrl = toProxyUrl(url);
|
|
147
|
+
|
|
148
|
+
// Append store key params so the proxy can save to ContentStore
|
|
149
|
+
if (proxyUrl.startsWith('/file-proxy')) {
|
|
150
|
+
const storeKey = `${this.fileInfo.type || 'media'}/${this.fileInfo.id}`;
|
|
151
|
+
proxyUrl += `&storeKey=${encodeURIComponent(storeKey)}`;
|
|
152
|
+
if (this.chunkIndex != null) {
|
|
153
|
+
proxyUrl += `&chunkIndex=${this.chunkIndex}`;
|
|
154
|
+
if (this._parentFile) {
|
|
155
|
+
proxyUrl += `&numChunks=${this._parentFile.totalChunks}`;
|
|
156
|
+
proxyUrl += `&chunkSize=${this._parentFile.options.chunkSize || 104857600}`;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (this.fileInfo.md5) {
|
|
160
|
+
proxyUrl += `&md5=${encodeURIComponent(this.fileInfo.md5)}`;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return proxyUrl;
|
|
146
164
|
}
|
|
147
165
|
|
|
148
166
|
async start() {
|
|
@@ -233,7 +251,17 @@ export class FileDownload {
|
|
|
233
251
|
if (isUrlExpired(url)) {
|
|
234
252
|
throw new Error(`URL expired for ${this.fileInfo.type}/${this.fileInfo.id} — waiting for fresh URL from next collection cycle`);
|
|
235
253
|
}
|
|
236
|
-
|
|
254
|
+
let proxyUrl = toProxyUrl(url);
|
|
255
|
+
|
|
256
|
+
// Append store key for ContentStore (same as DownloadTask)
|
|
257
|
+
if (proxyUrl.startsWith('/file-proxy')) {
|
|
258
|
+
const storeKey = `${this.fileInfo.type || 'media'}/${this.fileInfo.id}`;
|
|
259
|
+
proxyUrl += `&storeKey=${encodeURIComponent(storeKey)}`;
|
|
260
|
+
if (this.fileInfo.md5) {
|
|
261
|
+
proxyUrl += `&md5=${encodeURIComponent(this.fileInfo.md5)}`;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return proxyUrl;
|
|
237
265
|
}
|
|
238
266
|
|
|
239
267
|
wait() {
|
|
@@ -578,6 +606,9 @@ export class DownloadQueue {
|
|
|
578
606
|
|
|
579
607
|
// When paused, processQueue() is a no-op (used during barrier setup)
|
|
580
608
|
this.paused = false;
|
|
609
|
+
|
|
610
|
+
// Track getData re-enqueue timers so clear() can cancel them
|
|
611
|
+
this._reenqueueTimers = new Set();
|
|
581
612
|
}
|
|
582
613
|
|
|
583
614
|
static stableKey(fileInfo) {
|
|
@@ -875,14 +906,23 @@ export class DownloadQueue {
|
|
|
875
906
|
// getData (widget data): defer re-enqueue instead of permanent failure.
|
|
876
907
|
// CMS "cache not ready" resolves when the XTR task runs (30-120s).
|
|
877
908
|
if (task.isGetData) {
|
|
878
|
-
|
|
879
|
-
|
|
909
|
+
task._reenqueueCount = (task._reenqueueCount || 0) + 1;
|
|
910
|
+
if (task._reenqueueCount > GETDATA_MAX_REENQUEUES) {
|
|
911
|
+
log.error(`[DownloadQueue] getData ${key} exceeded ${GETDATA_MAX_REENQUEUES} re-enqueues, failing permanently`);
|
|
912
|
+
this.processQueue();
|
|
913
|
+
task._parentFile.onTaskFailed(task, err);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
log.warn(`[DownloadQueue] getData ${key} failed all retries (attempt ${task._reenqueueCount}/${GETDATA_MAX_REENQUEUES}), scheduling re-enqueue in ${GETDATA_REENQUEUE_DELAY_MS / 1000}s`);
|
|
917
|
+
const timerId = setTimeout(() => {
|
|
918
|
+
this._reenqueueTimers.delete(timerId);
|
|
880
919
|
task.state = 'pending';
|
|
881
920
|
task._parentFile.state = 'downloading';
|
|
882
921
|
this.queue.push(task);
|
|
883
922
|
log.info(`[DownloadQueue] getData ${key} re-enqueued for retry`);
|
|
884
923
|
this.processQueue();
|
|
885
924
|
}, GETDATA_REENQUEUE_DELAY_MS);
|
|
925
|
+
this._reenqueueTimers.add(timerId);
|
|
886
926
|
this.processQueue();
|
|
887
927
|
return;
|
|
888
928
|
}
|
|
@@ -941,6 +981,9 @@ export class DownloadQueue {
|
|
|
941
981
|
this.running = 0;
|
|
942
982
|
this._prepareQueue = [];
|
|
943
983
|
this._preparingCount = 0;
|
|
984
|
+
// Cancel any pending getData re-enqueue timers
|
|
985
|
+
for (const id of this._reenqueueTimers) clearTimeout(id);
|
|
986
|
+
this._reenqueueTimers.clear();
|
|
944
987
|
}
|
|
945
988
|
}
|
|
946
989
|
|
package/src/index.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import pkg from '../package.json' with { type: 'json' };
|
|
3
3
|
export const VERSION = pkg.version;
|
|
4
4
|
export { CacheManager, cacheManager } from './cache.js';
|
|
5
|
-
export {
|
|
6
|
-
export {
|
|
5
|
+
export { StoreClient } from './store-client.js';
|
|
6
|
+
export { DownloadClient } from './download-client.js';
|
|
7
|
+
export { DownloadManager, FileDownload, LayoutTaskBuilder, isUrlExpired, toProxyUrl } from './download-manager.js';
|
|
7
8
|
export { CacheAnalyzer } from './cache-analyzer.js';
|
|
8
9
|
export { cacheWidgetHtml } from './widget-html.js';
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StoreClient — Pure REST client for ContentStore
|
|
3
|
+
*
|
|
4
|
+
* Communicates with the proxy's /store/* endpoints via fetch().
|
|
5
|
+
* No Service Worker dependency — works immediately after construction.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const store = new StoreClient();
|
|
9
|
+
* const exists = await store.has('media', '123');
|
|
10
|
+
* const blob = await store.get('media', '123');
|
|
11
|
+
* await store.put('widget', 'layout/1/region/2/media/3', htmlBlob, 'text/html');
|
|
12
|
+
* await store.remove([{ type: 'media', id: '456' }]);
|
|
13
|
+
* const files = await store.list();
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createLogger } from '@xiboplayer/utils';
|
|
17
|
+
|
|
18
|
+
const log = createLogger('StoreClient');
|
|
19
|
+
|
|
20
|
+
export class StoreClient {
|
|
21
|
+
/**
|
|
22
|
+
* Check if a file exists in the store.
|
|
23
|
+
* @param {string} type - 'media', 'layout', 'widget', 'static'
|
|
24
|
+
* @param {string} id - File ID or path
|
|
25
|
+
* @returns {Promise<boolean>}
|
|
26
|
+
*/
|
|
27
|
+
async has(type, id) {
|
|
28
|
+
try {
|
|
29
|
+
const response = await fetch(`/store/${type}/${id}`, { method: 'HEAD' });
|
|
30
|
+
return response.ok;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get a file from the store as a Blob.
|
|
38
|
+
* @param {string} type - 'media', 'layout', 'widget', 'static'
|
|
39
|
+
* @param {string} id - File ID or path
|
|
40
|
+
* @returns {Promise<Blob|null>}
|
|
41
|
+
*/
|
|
42
|
+
async get(type, id) {
|
|
43
|
+
try {
|
|
44
|
+
const response = await fetch(`/store/${type}/${id}`);
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
response.body?.cancel();
|
|
47
|
+
if (response.status === 404) return null;
|
|
48
|
+
throw new Error(`Failed to get file: ${response.status}`);
|
|
49
|
+
}
|
|
50
|
+
return await response.blob();
|
|
51
|
+
} catch (error) {
|
|
52
|
+
log.error('get error:', error.message);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Store a file in the ContentStore.
|
|
59
|
+
* @param {string} type - 'media', 'layout', 'widget', 'static'
|
|
60
|
+
* @param {string} id - File ID or path
|
|
61
|
+
* @param {Blob|ArrayBuffer|string} body - Content to store
|
|
62
|
+
* @param {string} [contentType='application/octet-stream'] - MIME type
|
|
63
|
+
* @returns {Promise<boolean>} true if stored successfully
|
|
64
|
+
*/
|
|
65
|
+
async put(type, id, body, contentType = 'application/octet-stream') {
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(`/store/${type}/${id}`, {
|
|
68
|
+
method: 'PUT',
|
|
69
|
+
headers: { 'Content-Type': contentType },
|
|
70
|
+
body,
|
|
71
|
+
});
|
|
72
|
+
response.body?.cancel();
|
|
73
|
+
return response.ok;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
log.error('put error:', error.message);
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Delete files from the store.
|
|
82
|
+
* @param {Array<{type: string, id: string}>} files - Files to delete
|
|
83
|
+
* @returns {Promise<{deleted: number, total: number}>}
|
|
84
|
+
*/
|
|
85
|
+
async remove(files) {
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch('/store/delete', {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: { 'Content-Type': 'application/json' },
|
|
90
|
+
body: JSON.stringify({ files }),
|
|
91
|
+
});
|
|
92
|
+
const result = await response.json();
|
|
93
|
+
return { deleted: result.deleted || 0, total: result.total || files.length };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
log.error('remove error:', error.message);
|
|
96
|
+
return { deleted: 0, total: files.length };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* List all files in the store.
|
|
102
|
+
* @returns {Promise<Array<{id: string, type: string, size: number}>>}
|
|
103
|
+
*/
|
|
104
|
+
async list() {
|
|
105
|
+
try {
|
|
106
|
+
const response = await fetch('/store/list');
|
|
107
|
+
const data = await response.json();
|
|
108
|
+
return data.files || [];
|
|
109
|
+
} catch (error) {
|
|
110
|
+
log.error('list error:', error.message);
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|