@xiboplayer/cache 0.5.8 → 0.5.9

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/src/cache.js CHANGED
@@ -1,17 +1,15 @@
1
1
  /**
2
2
  * CacheManager - Dependant tracking and cache lifecycle
3
3
  *
4
- * After the cache unification, all downloads and file retrieval go through
5
- * the Service Worker (via CacheProxy). This class retains:
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. Download/IndexedDB methods have been removed (handled by SW).
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 caches and dependants', async () => {
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
+ }
@@ -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 rewriteUrlForProxy(url) {
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
- return rewriteUrlForProxy(url);
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
- return rewriteUrlForProxy(url);
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
- log.warn(`[DownloadQueue] getData ${key} failed all retries, scheduling re-enqueue in ${GETDATA_REENQUEUE_DELAY_MS / 1000}s`);
879
- setTimeout(() => {
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 { CacheProxy } from './cache-proxy.js';
6
- export { DownloadManager, FileDownload, LayoutTaskBuilder, isUrlExpired } from './download-manager.js';
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
+ }