@xiboplayer/cache 0.5.7 → 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.
@@ -1,532 +0,0 @@
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 {Object|Array} payload - Either { layouts: [{ layoutId, mediaFiles }] } or flat Array of files
174
- * @returns {Promise<void>}
175
- */
176
- async requestDownload(payload) {
177
- if (!this.controller) {
178
- throw new Error('Service Worker not available');
179
- }
180
-
181
- // Support both grouped and flat payload (backward compat)
182
- const data = Array.isArray(payload)
183
- ? { files: payload }
184
- : payload;
185
-
186
- return new Promise((resolve, reject) => {
187
- const messageChannel = new MessageChannel();
188
-
189
- messageChannel.port1.onmessage = (event) => {
190
- const { success, error, enqueuedCount, activeCount, queuedCount } = event.data;
191
- if (success) {
192
- log.info('Download request acknowledged:', enqueuedCount, 'files');
193
- log.info('Queue state:', activeCount, 'active,', queuedCount, 'queued');
194
- resolve();
195
- } else {
196
- reject(new Error(error || 'Service Worker download failed'));
197
- }
198
- };
199
-
200
- this.controller.postMessage(
201
- {
202
- type: 'DOWNLOAD_FILES',
203
- data
204
- },
205
- [messageChannel.port2]
206
- );
207
- });
208
- }
209
-
210
- /**
211
- * Tell SW to prioritize downloading a specific file
212
- * Moves it to the front of the download queue if still queued
213
- * @param {string} fileType - 'media' or 'layout'
214
- * @param {string} fileId - File ID
215
- */
216
- async prioritizeDownload(fileType, fileId) {
217
- if (!this.controller) return;
218
-
219
- return new Promise((resolve) => {
220
- const messageChannel = new MessageChannel();
221
- messageChannel.port1.onmessage = (event) => resolve(event.data);
222
- this.controller.postMessage(
223
- { type: 'PRIORITIZE_DOWNLOAD', data: { fileType, fileId } },
224
- [messageChannel.port2]
225
- );
226
- });
227
- }
228
-
229
- /**
230
- * Check if file is cached (delegates to hasFile for consistent fetchReady handling)
231
- * @param {string} type - 'media', 'layout', 'widget'
232
- * @param {string} id - File ID
233
- * @returns {Promise<boolean>}
234
- */
235
- async isCached(type, id) {
236
- return this.hasFile(type, id);
237
- }
238
-
239
- /**
240
- * Prioritize layout files — reorder queue and hold other downloads until done.
241
- * @param {string[]} mediaIds - Media IDs needed by the current layout
242
- */
243
- async prioritizeLayoutFiles(mediaIds) {
244
- if (!this.controller) return;
245
- this.controller.postMessage({ type: 'PRIORITIZE_LAYOUT_FILES', data: { mediaIds } });
246
- }
247
- }
248
-
249
- // DirectCacheBackend removed - Service Worker only architecture
250
-
251
- /**
252
- * CacheProxy - Service Worker only interface
253
- */
254
- export class CacheProxy extends EventEmitter {
255
- constructor() {
256
- super();
257
- this.backend = null;
258
- this.backendType = 'service-worker';
259
- }
260
-
261
- /**
262
- * Initialize proxy - WAITS for Service Worker to be ready
263
- */
264
- async init() {
265
- if (!('serviceWorker' in navigator)) {
266
- throw new Error('Service Worker not supported - PWA requires Service Worker');
267
- }
268
-
269
- log.debug('Checking Service Worker state...');
270
- log.debug('controller =', navigator.serviceWorker.controller);
271
-
272
- // Check if SW registration exists (better than checking controller)
273
- const registration = await navigator.serviceWorker.getRegistration();
274
- log.debug('registration =', registration);
275
- log.debug('active =', registration?.active);
276
- log.debug('installing =', registration?.installing);
277
- log.debug('waiting =', registration?.waiting);
278
-
279
- // FAST PATH: If active SW exists AND no new SW is installing, use it immediately
280
- if (registration && registration.active && !registration.installing && !registration.waiting) {
281
- log.info('Active Service Worker found (no updates pending)');
282
- log.debug('SW state =', registration.active.state);
283
-
284
- // If not controlling yet, give it a moment to claim page
285
- if (!navigator.serviceWorker.controller) {
286
- log.debug('Not controlling yet, waiting 200ms for claim...');
287
- await new Promise(resolve => setTimeout(resolve, 200));
288
- log.debug('After wait, controller =', navigator.serviceWorker.controller);
289
- }
290
-
291
- // Use the active SW (even if controller is still null - it will work)
292
- this.backend = new ServiceWorkerBackend();
293
- await this.backend.init();
294
- log.info('Service Worker backend ready (fast path)');
295
- return;
296
- }
297
-
298
- // If there's a new SW installing/waiting, wait for it instead of using old one
299
- if (registration && (registration.installing || registration.waiting)) {
300
- log.info('New Service Worker detected, waiting for it to activate...');
301
- log.debug('installing =', registration.installing?.state);
302
- log.debug('waiting =', registration.waiting?.state);
303
- }
304
-
305
- // SLOW PATH: No active SW, wait for registration (fresh install)
306
- log.info('No active Service Worker, waiting for registration...');
307
-
308
- // Wait with timeout
309
- const swReady = navigator.serviceWorker.ready;
310
- const timeout = new Promise((_, reject) =>
311
- setTimeout(() => reject(new Error('Service Worker ready timeout after 10s')), 10000)
312
- );
313
-
314
- try {
315
- await Promise.race([swReady, timeout]);
316
- log.debug('Service Worker ready promise resolved');
317
- } catch (error) {
318
- log.error('Service Worker wait failed:', error);
319
- throw new Error('Service Worker not ready - please reload page');
320
- }
321
-
322
- // Wait for SW to claim page
323
- await new Promise(resolve => setTimeout(resolve, 100));
324
- log.debug('After claim wait, controller =', navigator.serviceWorker.controller);
325
-
326
- // Controller not required - we can use registration.active instead
327
- // This handles the case where SW is active but hasn't set controller yet (timing issue)
328
- this.backend = new ServiceWorkerBackend();
329
- await this.backend.init();
330
- log.info('Service Worker backend ready (slow path)');
331
- }
332
-
333
- /**
334
- * Get file from cache
335
- * @param {string} type - 'media', 'layout', 'widget'
336
- * @param {string} id - File ID
337
- * @returns {Promise<Blob|null>}
338
- */
339
- async getFile(type, id) {
340
- if (!this.backend) {
341
- throw new Error('CacheProxy not initialized');
342
- }
343
- return await this.backend.getFile(type, id);
344
- }
345
-
346
- /**
347
- * Check if file exists in cache (for streaming - no blob creation)
348
- * @param {string} type - 'media', 'layout', 'widget'
349
- * @param {string} id - File ID
350
- * @returns {Promise<boolean>}
351
- */
352
- async hasFile(type, id) {
353
- if (!this.backend) {
354
- throw new Error('CacheProxy not initialized');
355
- }
356
- return await this.backend.hasFile(type, id);
357
- }
358
-
359
- /**
360
- * Request file downloads
361
- * Service Worker: Non-blocking (downloads in background)
362
- * Direct cache: Blocking (downloads sequentially)
363
- *
364
- * @param {Array} files - Array of { id, type, path, md5 }
365
- * @returns {Promise<void>}
366
- */
367
- async requestDownload(files) {
368
- if (!this.backend) {
369
- throw new Error('CacheProxy not initialized');
370
- }
371
- return await this.backend.requestDownload(files);
372
- }
373
-
374
- /**
375
- * Prioritize downloading a specific file (move to front of queue)
376
- * @param {string} fileType - 'media' or 'layout'
377
- * @param {string} fileId - File ID
378
- */
379
- async prioritizeDownload(fileType, fileId) {
380
- if (!this.backend?.prioritizeDownload) return;
381
- return await this.backend.prioritizeDownload(fileType, fileId);
382
- }
383
-
384
- /**
385
- * Check if file is cached (delegates to hasFile for consistent fetchReady handling)
386
- * @param {string} type - 'media', 'layout', 'widget'
387
- * @param {string} id - File ID
388
- * @returns {Promise<boolean>}
389
- */
390
- async isCached(type, id) {
391
- return this.hasFile(type, id);
392
- }
393
-
394
- /**
395
- * Prioritize layout files — reorder queue and hold other downloads until done.
396
- * @param {string[]} mediaIds - Media IDs needed by the current layout
397
- */
398
- async prioritizeLayoutFiles(mediaIds) {
399
- if (!this.backend?.prioritizeLayoutFiles) return;
400
- return await this.backend.prioritizeLayoutFiles(mediaIds);
401
- }
402
-
403
- /**
404
- * Get backend type for debugging
405
- * @returns {string} 'service-worker' or 'direct'
406
- */
407
- getBackendType() {
408
- return this.backendType;
409
- }
410
-
411
- /**
412
- * Check if Service Worker is being used
413
- * @returns {boolean}
414
- */
415
- isUsingServiceWorker() {
416
- return this.backendType === 'service-worker';
417
- }
418
-
419
- /**
420
- * Get all cached files from Service Worker
421
- * @returns {Promise<Array<{id: string, type: string, size: number, cachedAt: number}>>}
422
- */
423
- async getAllFiles() {
424
- if (!this.backend) {
425
- throw new Error('CacheProxy not initialized');
426
- }
427
-
428
- return new Promise((resolve) => {
429
- const channel = new MessageChannel();
430
-
431
- channel.port1.onmessage = (event) => {
432
- const { success, files } = event.data;
433
- resolve(success ? files : []);
434
- };
435
-
436
- navigator.serviceWorker.controller.postMessage(
437
- { type: 'GET_ALL_FILES' },
438
- [channel.port2]
439
- );
440
-
441
- setTimeout(() => resolve([]), 5000);
442
- });
443
- }
444
-
445
- /**
446
- * Delete files from cache (purge obsolete media)
447
- * @param {Array<{type: string, id: string}>} files - Files to delete
448
- * @returns {Promise<{deleted: number, total: number}>}
449
- */
450
- async deleteFiles(files) {
451
- if (!this.backend) {
452
- throw new Error('CacheProxy not initialized');
453
- }
454
-
455
- return new Promise((resolve, reject) => {
456
- const channel = new MessageChannel();
457
-
458
- channel.port1.onmessage = (event) => {
459
- const { success, error, deleted, total } = event.data;
460
- if (success) {
461
- resolve({ deleted, total });
462
- } else {
463
- reject(new Error(error || 'Delete failed'));
464
- }
465
- };
466
-
467
- navigator.serviceWorker.controller.postMessage(
468
- { type: 'DELETE_FILES', data: { files } },
469
- [channel.port2]
470
- );
471
-
472
- setTimeout(() => resolve({ deleted: 0, total: files.length }), 5000);
473
- });
474
- }
475
-
476
- /**
477
- * Pre-warm video chunks into SW BlobCache for faster playback startup.
478
- * Loads first and last chunks into memory so moov atom probing is instant.
479
- * @param {number[]} mediaIds - Media file IDs to pre-warm
480
- * @returns {Promise<{warmed: number, total: number}>}
481
- */
482
- async prewarmVideoChunks(mediaIds) {
483
- if (!mediaIds || mediaIds.length === 0) return { warmed: 0, total: 0 };
484
-
485
- const controller = navigator.serviceWorker?.controller;
486
- if (!controller) return { warmed: 0, total: mediaIds.length };
487
-
488
- return new Promise((resolve) => {
489
- const channel = new MessageChannel();
490
-
491
- channel.port1.onmessage = (event) => {
492
- const { success, warmed, total } = event.data;
493
- resolve(success ? { warmed, total } : { warmed: 0, total: mediaIds.length });
494
- };
495
-
496
- controller.postMessage(
497
- { type: 'PREWARM_VIDEO_CHUNKS', data: { mediaIds } },
498
- [channel.port2]
499
- );
500
-
501
- // Don't block layout rendering for more than 2s
502
- setTimeout(() => resolve({ warmed: 0, total: mediaIds.length }), 2000);
503
- });
504
- }
505
-
506
- /**
507
- * Get download progress from Service Worker
508
- * @returns {Promise<Object>} Progress info for all active downloads
509
- */
510
- async getDownloadProgress() {
511
- if (!this.backend) {
512
- throw new Error('CacheProxy not initialized');
513
- }
514
-
515
- return new Promise((resolve) => {
516
- const channel = new MessageChannel();
517
-
518
- channel.port1.onmessage = (event) => {
519
- const { success, progress } = event.data;
520
- resolve(success ? progress : {});
521
- };
522
-
523
- navigator.serviceWorker.controller.postMessage(
524
- { type: 'GET_DOWNLOAD_PROGRESS' },
525
- [channel.port2]
526
- );
527
-
528
- // Timeout after 1 second
529
- setTimeout(() => resolve({}), 1000);
530
- });
531
- }
532
- }