@xiboplayer/cache 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.
@@ -0,0 +1,493 @@
1
+ /**
2
+ * CacheProxy - Service Worker cache interface
3
+ *
4
+ * Provides a proxy to Service Worker for background downloads and caching.
5
+ * Service Worker MUST be available - no fallback (committed to SW architecture).
6
+ *
7
+ * Architecture:
8
+ * ┌─────────────────────────────────────────┐
9
+ * │ CacheProxy (Service Worker Only) │
10
+ * │ - Waits for Service Worker ready │
11
+ * │ - Routes all requests to SW │
12
+ * │ - No fallback to direct cache │
13
+ * └─────────────────────────────────────────┘
14
+ * ↓
15
+ * ┌──────────────────┐
16
+ * │ ServiceWorker │
17
+ * │ Backend │
18
+ * │ - postMessage │
19
+ * │ - Background DL │
20
+ * └──────────────────┘
21
+ *
22
+ * Usage:
23
+ * const proxy = new CacheProxy();
24
+ * await proxy.init(); // Waits for SW to be ready
25
+ *
26
+ * const blob = await proxy.getFile('media', '123');
27
+ * await proxy.requestDownload([{ id, type, path, md5 }]);
28
+ * const isCached = await proxy.isCached('layout', '456');
29
+ */
30
+
31
+ import { EventEmitter, createLogger } from '@xiboplayer/utils';
32
+
33
+ const log = createLogger('CacheProxy');
34
+
35
+ // Dynamic base path for multi-variant deployment (pwa, pwa-xmds, pwa-xlr)
36
+ const BASE = (typeof window !== 'undefined')
37
+ ? window.location.pathname.replace(/\/[^/]*$/, '').replace(/\/$/, '') || '/player/pwa'
38
+ : '/player/pwa';
39
+
40
+ /**
41
+ * ServiceWorkerBackend - Uses Service Worker for downloads and caching
42
+ */
43
+ class ServiceWorkerBackend extends EventEmitter {
44
+ constructor() {
45
+ super();
46
+ this.controller = null;
47
+ this.fetchReady = false;
48
+ this.fetchReadyPromise = null;
49
+ this.fetchReadyResolve = null;
50
+ }
51
+
52
+ async init() {
53
+ // Create promise for fetch readiness (resolved when SW sends SW_READY)
54
+ this.fetchReadyPromise = new Promise(resolve => {
55
+ this.fetchReadyResolve = resolve;
56
+ });
57
+
58
+ // Listen for SW_READY message
59
+ navigator.serviceWorker.addEventListener('message', (event) => {
60
+ if (event.data?.type === 'SW_READY') {
61
+ log.info('Received SW_READY signal - fetch handler is ready');
62
+ this.fetchReady = true;
63
+ this.fetchReadyResolve();
64
+ }
65
+ });
66
+
67
+ // Get the active Service Worker (don't require controller)
68
+ if ('serviceWorker' in navigator) {
69
+ const registration = await navigator.serviceWorker.getRegistration();
70
+
71
+ // Use active SW if it exists (controller not required!)
72
+ if (registration && registration.active && registration.active.state === 'activated') {
73
+ log.info('Using active Service Worker (controller not required)');
74
+ this.controller = navigator.serviceWorker.controller || registration.active;
75
+
76
+ // Request readiness signal from SW
77
+ this.controller.postMessage({ type: 'PING' });
78
+
79
+ log.info('Service Worker backend initialized, waiting for fetch readiness...');
80
+ return;
81
+ }
82
+
83
+ // Fall back to waiting for ready
84
+ await navigator.serviceWorker.ready;
85
+ this.controller = navigator.serviceWorker.controller;
86
+
87
+ if (!this.controller) {
88
+ throw new Error('Service Worker not controlling page');
89
+ }
90
+
91
+ // Request readiness signal from SW
92
+ this.controller.postMessage({ type: 'PING' });
93
+
94
+ log.info('Service Worker backend initialized, waiting for fetch readiness...');
95
+ } else {
96
+ throw new Error('Service Worker not supported');
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Get file from cache (via Service Worker)
102
+ * @param {string} type - 'media', 'layout', 'widget'
103
+ * @param {string} id - File ID
104
+ * @returns {Promise<Blob|null>}
105
+ */
106
+ async getFile(type, id) {
107
+ // Wait for SW fetch handler to be ready (eliminates race condition)
108
+ if (!this.fetchReady) {
109
+ log.debug(`Waiting for SW fetch handler to be ready before fetching ${type}/${id}...`);
110
+ await this.fetchReadyPromise;
111
+ log.debug(`SW fetch handler ready, proceeding with fetch`);
112
+ }
113
+
114
+ // Service Worker serves files via fetch interception
115
+ // Construct cache URL and fetch it
116
+ const cacheUrl = `${BASE}/cache/${type}/${id}`;
117
+
118
+ log.debug(`getFile(${type}, ${id}) → fetching ${cacheUrl}`);
119
+ log.debug(`About to call fetch()...`);
120
+
121
+ try {
122
+ log.debug(`Calling fetch(${cacheUrl})...`);
123
+ const response = await fetch(cacheUrl);
124
+ log.debug(`fetch returned, status:`, response.status, response.statusText);
125
+
126
+ if (!response.ok) {
127
+ log.debug(`Response not OK (${response.status}), returning null`);
128
+ if (response.status === 404) {
129
+ return null; // Not cached
130
+ }
131
+ throw new Error(`Failed to get file: ${response.status}`);
132
+ }
133
+
134
+ log.debug(`Response OK, getting blob...`);
135
+ const blob = await response.blob();
136
+ log.debug(`Got blob, size:`, blob.size);
137
+ return blob;
138
+ } catch (error) {
139
+ log.error('getFile EXCEPTION:', error);
140
+ log.error('Error name:', error.name);
141
+ log.error('Error message:', error.message);
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Check if file exists in cache (supports both whole files and chunked storage)
148
+ * Service Worker's CacheManager.fileExists() handles the logic internally
149
+ * @param {string} type - 'media', 'layout', 'widget'
150
+ * @param {string} id - File ID
151
+ * @returns {Promise<boolean>}
152
+ */
153
+ async hasFile(type, id) {
154
+ // Wait for SW fetch handler to be ready
155
+ if (!this.fetchReady) {
156
+ await this.fetchReadyPromise;
157
+ }
158
+
159
+ const cacheUrl = `${BASE}/cache/${type}/${id}`;
160
+
161
+ try {
162
+ // SW's handleRequest uses CacheManager.fileExists() internally
163
+ // Returns 200 for both whole files and chunked files (via metadata check)
164
+ const response = await fetch(cacheUrl, { method: 'HEAD' });
165
+ return response.ok;
166
+ } catch (error) {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Request downloads from Service Worker (non-blocking)
173
+ * @param {Array} files - Array of { id, type, path, md5 }
174
+ * @returns {Promise<void>}
175
+ */
176
+ async requestDownload(files) {
177
+ if (!this.controller) {
178
+ throw new Error('Service Worker not available');
179
+ }
180
+
181
+ return new Promise((resolve, reject) => {
182
+ const messageChannel = new MessageChannel();
183
+
184
+ messageChannel.port1.onmessage = (event) => {
185
+ const { success, error, enqueuedCount, activeCount, queuedCount } = event.data;
186
+ if (success) {
187
+ log.info('Download request acknowledged:', enqueuedCount, 'files');
188
+ log.info('Queue state:', activeCount, 'active,', queuedCount, 'queued');
189
+ resolve();
190
+ } else {
191
+ reject(new Error(error || 'Service Worker download failed'));
192
+ }
193
+ };
194
+
195
+ this.controller.postMessage(
196
+ {
197
+ type: 'DOWNLOAD_FILES',
198
+ data: { files }
199
+ },
200
+ [messageChannel.port2]
201
+ );
202
+ });
203
+ }
204
+
205
+ /**
206
+ * Tell SW to prioritize downloading a specific file
207
+ * Moves it to the front of the download queue if still queued
208
+ * @param {string} fileType - 'media' or 'layout'
209
+ * @param {string} fileId - File ID
210
+ */
211
+ async prioritizeDownload(fileType, fileId) {
212
+ if (!this.controller) return;
213
+
214
+ return new Promise((resolve) => {
215
+ const messageChannel = new MessageChannel();
216
+ messageChannel.port1.onmessage = (event) => resolve(event.data);
217
+ this.controller.postMessage(
218
+ { type: 'PRIORITIZE_DOWNLOAD', data: { fileType, fileId } },
219
+ [messageChannel.port2]
220
+ );
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Check if file is cached
226
+ * @param {string} type - 'media', 'layout', 'widget'
227
+ * @param {string} id - File ID
228
+ * @returns {Promise<boolean>}
229
+ */
230
+ async isCached(type, id) {
231
+ const cacheUrl = `${BASE}/cache/${type}/${id}`;
232
+
233
+ try {
234
+ const response = await fetch(cacheUrl, { method: 'HEAD' });
235
+ return response.ok;
236
+ } catch (error) {
237
+ return false;
238
+ }
239
+ }
240
+ }
241
+
242
+ // DirectCacheBackend removed - Service Worker only architecture
243
+
244
+ /**
245
+ * CacheProxy - Service Worker only interface
246
+ */
247
+ export class CacheProxy extends EventEmitter {
248
+ constructor() {
249
+ super();
250
+ this.backend = null;
251
+ this.backendType = 'service-worker';
252
+ }
253
+
254
+ /**
255
+ * Initialize proxy - WAITS for Service Worker to be ready
256
+ */
257
+ async init() {
258
+ if (!('serviceWorker' in navigator)) {
259
+ throw new Error('Service Worker not supported - PWA requires Service Worker');
260
+ }
261
+
262
+ log.debug('Checking Service Worker state...');
263
+ log.debug('controller =', navigator.serviceWorker.controller);
264
+
265
+ // Check if SW registration exists (better than checking controller)
266
+ const registration = await navigator.serviceWorker.getRegistration();
267
+ log.debug('registration =', registration);
268
+ log.debug('active =', registration?.active);
269
+ log.debug('installing =', registration?.installing);
270
+ log.debug('waiting =', registration?.waiting);
271
+
272
+ // FAST PATH: If active SW exists AND no new SW is installing, use it immediately
273
+ if (registration && registration.active && !registration.installing && !registration.waiting) {
274
+ log.info('Active Service Worker found (no updates pending)');
275
+ log.debug('SW state =', registration.active.state);
276
+
277
+ // If not controlling yet, give it a moment to claim page
278
+ if (!navigator.serviceWorker.controller) {
279
+ log.debug('Not controlling yet, waiting 200ms for claim...');
280
+ await new Promise(resolve => setTimeout(resolve, 200));
281
+ log.debug('After wait, controller =', navigator.serviceWorker.controller);
282
+ }
283
+
284
+ // Use the active SW (even if controller is still null - it will work)
285
+ this.backend = new ServiceWorkerBackend();
286
+ await this.backend.init();
287
+ log.info('Service Worker backend ready (fast path)');
288
+ return;
289
+ }
290
+
291
+ // If there's a new SW installing/waiting, wait for it instead of using old one
292
+ if (registration && (registration.installing || registration.waiting)) {
293
+ log.info('New Service Worker detected, waiting for it to activate...');
294
+ log.debug('installing =', registration.installing?.state);
295
+ log.debug('waiting =', registration.waiting?.state);
296
+ }
297
+
298
+ // SLOW PATH: No active SW, wait for registration (fresh install)
299
+ log.info('No active Service Worker, waiting for registration...');
300
+
301
+ // Wait with timeout
302
+ const swReady = navigator.serviceWorker.ready;
303
+ const timeout = new Promise((_, reject) =>
304
+ setTimeout(() => reject(new Error('Service Worker ready timeout after 10s')), 10000)
305
+ );
306
+
307
+ try {
308
+ await Promise.race([swReady, timeout]);
309
+ log.debug('Service Worker ready promise resolved');
310
+ } catch (error) {
311
+ log.error('Service Worker wait failed:', error);
312
+ throw new Error('Service Worker not ready - please reload page');
313
+ }
314
+
315
+ // Wait for SW to claim page
316
+ await new Promise(resolve => setTimeout(resolve, 100));
317
+ log.debug('After claim wait, controller =', navigator.serviceWorker.controller);
318
+
319
+ // Controller not required - we can use registration.active instead
320
+ // This handles the case where SW is active but hasn't set controller yet (timing issue)
321
+ this.backend = new ServiceWorkerBackend();
322
+ await this.backend.init();
323
+ log.info('Service Worker backend ready (slow path)');
324
+ }
325
+
326
+ /**
327
+ * Get file from cache
328
+ * @param {string} type - 'media', 'layout', 'widget'
329
+ * @param {string} id - File ID
330
+ * @returns {Promise<Blob|null>}
331
+ */
332
+ async getFile(type, id) {
333
+ if (!this.backend) {
334
+ throw new Error('CacheProxy not initialized');
335
+ }
336
+ return await this.backend.getFile(type, id);
337
+ }
338
+
339
+ /**
340
+ * Check if file exists in cache (for streaming - no blob creation)
341
+ * @param {string} type - 'media', 'layout', 'widget'
342
+ * @param {string} id - File ID
343
+ * @returns {Promise<boolean>}
344
+ */
345
+ async hasFile(type, id) {
346
+ if (!this.backend) {
347
+ throw new Error('CacheProxy not initialized');
348
+ }
349
+ return await this.backend.hasFile(type, id);
350
+ }
351
+
352
+ /**
353
+ * Request file downloads
354
+ * Service Worker: Non-blocking (downloads in background)
355
+ * Direct cache: Blocking (downloads sequentially)
356
+ *
357
+ * @param {Array} files - Array of { id, type, path, md5 }
358
+ * @returns {Promise<void>}
359
+ */
360
+ async requestDownload(files) {
361
+ if (!this.backend) {
362
+ throw new Error('CacheProxy not initialized');
363
+ }
364
+ return await this.backend.requestDownload(files);
365
+ }
366
+
367
+ /**
368
+ * Prioritize downloading a specific file (move to front of queue)
369
+ * @param {string} fileType - 'media' or 'layout'
370
+ * @param {string} fileId - File ID
371
+ */
372
+ async prioritizeDownload(fileType, fileId) {
373
+ if (!this.backend?.prioritizeDownload) return;
374
+ return await this.backend.prioritizeDownload(fileType, fileId);
375
+ }
376
+
377
+ /**
378
+ * Check if file is cached
379
+ * @param {string} type - 'media', 'layout', 'widget'
380
+ * @param {string} id - File ID
381
+ * @returns {Promise<boolean>}
382
+ */
383
+ async isCached(type, id) {
384
+ if (!this.backend) {
385
+ throw new Error('CacheProxy not initialized');
386
+ }
387
+ return await this.backend.isCached(type, id);
388
+ }
389
+
390
+ /**
391
+ * Get backend type for debugging
392
+ * @returns {string} 'service-worker' or 'direct'
393
+ */
394
+ getBackendType() {
395
+ return this.backendType;
396
+ }
397
+
398
+ /**
399
+ * Check if Service Worker is being used
400
+ * @returns {boolean}
401
+ */
402
+ isUsingServiceWorker() {
403
+ return this.backendType === 'service-worker';
404
+ }
405
+
406
+ /**
407
+ * Delete files from cache (purge obsolete media)
408
+ * @param {Array<{type: string, id: string}>} files - Files to delete
409
+ * @returns {Promise<{deleted: number, total: number}>}
410
+ */
411
+ async deleteFiles(files) {
412
+ if (!this.backend) {
413
+ throw new Error('CacheProxy not initialized');
414
+ }
415
+
416
+ return new Promise((resolve, reject) => {
417
+ const channel = new MessageChannel();
418
+
419
+ channel.port1.onmessage = (event) => {
420
+ const { success, error, deleted, total } = event.data;
421
+ if (success) {
422
+ resolve({ deleted, total });
423
+ } else {
424
+ reject(new Error(error || 'Delete failed'));
425
+ }
426
+ };
427
+
428
+ navigator.serviceWorker.controller.postMessage(
429
+ { type: 'DELETE_FILES', data: { files } },
430
+ [channel.port2]
431
+ );
432
+
433
+ setTimeout(() => resolve({ deleted: 0, total: files.length }), 5000);
434
+ });
435
+ }
436
+
437
+ /**
438
+ * Pre-warm video chunks into SW BlobCache for faster playback startup.
439
+ * Loads first and last chunks into memory so moov atom probing is instant.
440
+ * @param {number[]} mediaIds - Media file IDs to pre-warm
441
+ * @returns {Promise<{warmed: number, total: number}>}
442
+ */
443
+ async prewarmVideoChunks(mediaIds) {
444
+ if (!mediaIds || mediaIds.length === 0) return { warmed: 0, total: 0 };
445
+
446
+ const controller = navigator.serviceWorker?.controller;
447
+ if (!controller) return { warmed: 0, total: mediaIds.length };
448
+
449
+ return new Promise((resolve) => {
450
+ const channel = new MessageChannel();
451
+
452
+ channel.port1.onmessage = (event) => {
453
+ const { success, warmed, total } = event.data;
454
+ resolve(success ? { warmed, total } : { warmed: 0, total: mediaIds.length });
455
+ };
456
+
457
+ controller.postMessage(
458
+ { type: 'PREWARM_VIDEO_CHUNKS', data: { mediaIds } },
459
+ [channel.port2]
460
+ );
461
+
462
+ // Don't block layout rendering for more than 2s
463
+ setTimeout(() => resolve({ warmed: 0, total: mediaIds.length }), 2000);
464
+ });
465
+ }
466
+
467
+ /**
468
+ * Get download progress from Service Worker
469
+ * @returns {Promise<Object>} Progress info for all active downloads
470
+ */
471
+ async getDownloadProgress() {
472
+ if (!this.backend) {
473
+ throw new Error('CacheProxy not initialized');
474
+ }
475
+
476
+ return new Promise((resolve) => {
477
+ const channel = new MessageChannel();
478
+
479
+ channel.port1.onmessage = (event) => {
480
+ const { success, progress } = event.data;
481
+ resolve(success ? progress : {});
482
+ };
483
+
484
+ navigator.serviceWorker.controller.postMessage(
485
+ { type: 'GET_DOWNLOAD_PROGRESS' },
486
+ [channel.port2]
487
+ );
488
+
489
+ // Timeout after 1 second
490
+ setTimeout(() => resolve({}), 1000);
491
+ });
492
+ }
493
+ }