@xiboplayer/sw 0.2.0 → 0.3.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,708 @@
1
+ /**
2
+ * MessageHandler - Handles postMessage from client
3
+ *
4
+ * Manages download orchestration, cache population, and progress reporting.
5
+ * Uses XLF-driven media resolution to enqueue downloads in playback order.
6
+ */
7
+
8
+ import { LayoutTaskBuilder, BARRIER } from '@xiboplayer/cache/download-manager';
9
+ import { formatBytes, BASE } from './sw-utils.js';
10
+ import { SWLogger } from './chunk-config.js';
11
+ import { extractMediaIdsFromXlf } from './xlf-parser.js';
12
+
13
+ /** Content-type map for static widget resources (JS, CSS, fonts, SVG) */
14
+ const STATIC_CONTENT_TYPES = {
15
+ 'js': 'application/javascript',
16
+ 'css': 'text/css',
17
+ 'otf': 'font/otf',
18
+ 'ttf': 'font/ttf',
19
+ 'woff': 'font/woff',
20
+ 'woff2': 'font/woff2',
21
+ 'eot': 'application/vnd.ms-fontobject',
22
+ 'svg': 'image/svg+xml'
23
+ };
24
+
25
+ export class MessageHandler {
26
+ /**
27
+ * @param {Object} downloadManager - DownloadManager instance
28
+ * @param {import('./cache-manager.js').CacheManager} cacheManager
29
+ * @param {import('./blob-cache.js').BlobCache} blobCache
30
+ * @param {Object} config
31
+ * @param {number} config.chunkSize - Chunk size in bytes
32
+ * @param {number} config.chunkStorageThreshold - Files larger than this use chunked storage
33
+ * @param {string} [config.cacheName='xibo-media-v1'] - Media cache name
34
+ * @param {string} [config.staticCache='xibo-static-v1'] - Static cache name
35
+ */
36
+ constructor(downloadManager, cacheManager, blobCache, config) {
37
+ this.downloadManager = downloadManager;
38
+ this.cacheManager = cacheManager;
39
+ this.blobCache = blobCache;
40
+ this.config = {
41
+ cacheName: 'xibo-media-v1',
42
+ staticCache: 'xibo-static-v1',
43
+ ...config
44
+ };
45
+ this.log = new SWLogger('SW Message');
46
+
47
+ // Track in-progress chunk storage operations (cacheKey → Promise)
48
+ // Prevents serving chunked files before chunks are fully written to cache
49
+ this.pendingChunkStorage = new Map();
50
+ }
51
+
52
+ /**
53
+ * Handle message from client
54
+ */
55
+ async handleMessage(event) {
56
+ const { type, data } = event.data;
57
+
58
+ // Log progress polls at debug (high-frequency), everything else at info
59
+ if (type === 'GET_DOWNLOAD_PROGRESS') {
60
+ this.log.debug('Received:', type);
61
+ } else {
62
+ this.log.info('Received:', type);
63
+ }
64
+
65
+ switch (type) {
66
+ case 'PING':
67
+ // Client is checking if SW is ready - broadcast SW_READY to caller
68
+ this.log.info('PING received, broadcasting SW_READY');
69
+ // Send SW_READY back to the client that sent PING
70
+ const clients = await self.clients.matchAll();
71
+ clients.forEach(client => {
72
+ client.postMessage({ type: 'SW_READY' });
73
+ });
74
+ return { success: true };
75
+
76
+ case 'DOWNLOAD_FILES':
77
+ return await this.handleDownloadFiles(data);
78
+
79
+ case 'PRIORITIZE_DOWNLOAD':
80
+ return this.handlePrioritizeDownload(data.fileType, data.fileId);
81
+
82
+ case 'CLEAR_CACHE':
83
+ return await this.handleClearCache();
84
+
85
+ case 'GET_DOWNLOAD_PROGRESS':
86
+ return await this.handleGetProgress();
87
+
88
+ case 'DELETE_FILES':
89
+ return await this.handleDeleteFiles(data.files);
90
+
91
+ case 'PREWARM_VIDEO_CHUNKS':
92
+ return await this.handlePrewarmVideoChunks(data.mediaIds);
93
+
94
+ case 'PRIORITIZE_LAYOUT_FILES':
95
+ this.downloadManager.prioritizeLayoutFiles(data.mediaIds);
96
+ return { success: true };
97
+
98
+ case 'URGENT_CHUNK':
99
+ return this.handleUrgentChunk(data.fileType, data.fileId, data.chunkIndex);
100
+
101
+ default:
102
+ this.log.warn('Unknown message type:', type);
103
+ return { success: false, error: 'Unknown message type' };
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Handle DELETE_FILES message - purge obsolete files from cache
109
+ */
110
+ async handleDeleteFiles(files) {
111
+ if (!files || !Array.isArray(files)) {
112
+ return { success: false, error: 'No files provided' };
113
+ }
114
+
115
+ let deleted = 0;
116
+ for (const file of files) {
117
+ const cacheKey = `${BASE}/cache/${file.type}/${file.id}`;
118
+ const wasDeleted = await this.cacheManager.delete(cacheKey);
119
+ if (wasDeleted) {
120
+ this.log.info('Purged:', cacheKey);
121
+ deleted++;
122
+ } else {
123
+ this.log.debug('Not cached (skip purge):', cacheKey);
124
+ }
125
+ }
126
+
127
+ this.log.info(`Purge complete: ${deleted}/${files.length} files deleted`);
128
+ return { success: true, deleted, total: files.length };
129
+ }
130
+
131
+ /**
132
+ * Handle PREWARM_VIDEO_CHUNKS - pre-load first and last chunks into BlobCache
133
+ * for faster video startup (avoids IndexedDB reads on initial Range requests)
134
+ */
135
+ async handlePrewarmVideoChunks(mediaIds) {
136
+ if (!mediaIds || !Array.isArray(mediaIds) || mediaIds.length === 0) {
137
+ return { success: false, error: 'No mediaIds provided' };
138
+ }
139
+
140
+ let warmed = 0;
141
+ for (const mediaId of mediaIds) {
142
+ const cacheKey = `${BASE}/cache/media/${mediaId}`;
143
+ const metadata = await this.cacheManager.getMetadata(cacheKey);
144
+
145
+ if (metadata?.chunked) {
146
+ // Chunked file: pre-warm first chunk (ftyp/mdat) and last chunk (moov atom)
147
+ const lastChunk = metadata.numChunks - 1;
148
+ const chunksToWarm = [0];
149
+ if (lastChunk > 0) chunksToWarm.push(lastChunk);
150
+
151
+ for (const idx of chunksToWarm) {
152
+ const chunkKey = `${cacheKey}/chunk-${idx}`;
153
+ // Load into BlobCache (no-op if already cached)
154
+ await this.blobCache.get(chunkKey, async () => {
155
+ const resp = await this.cacheManager.getChunk(cacheKey, idx);
156
+ if (!resp) return new Blob(); // shouldn't happen for cached media
157
+ return await resp.blob();
158
+ });
159
+ }
160
+ this.log.info(`Pre-warmed ${chunksToWarm.length} chunks for media ${mediaId} (${metadata.numChunks} total)`);
161
+ warmed++;
162
+ } else {
163
+ // Whole file: pre-warm entire blob
164
+ const cached = await this.cacheManager.get(cacheKey);
165
+ if (cached) {
166
+ await this.blobCache.get(cacheKey, async () => await cached.clone().blob());
167
+ this.log.info(`Pre-warmed whole file for media ${mediaId}`);
168
+ warmed++;
169
+ }
170
+ }
171
+ }
172
+
173
+ return { success: true, warmed, total: mediaIds.length };
174
+ }
175
+
176
+ /**
177
+ * Handle PRIORITIZE_DOWNLOAD - move file to front of download queue
178
+ */
179
+ handlePrioritizeDownload(fileType, fileId) {
180
+ this.log.info('Prioritize request:', `${fileType}/${fileId}`);
181
+ const found = this.downloadManager.queue.prioritize(fileType, fileId);
182
+ // Trigger queue processing in case there's capacity
183
+ this.downloadManager.queue.processQueue();
184
+ return { success: true, found };
185
+ }
186
+
187
+ /**
188
+ * Handle URGENT_CHUNK — emergency priority for a stalled streaming chunk.
189
+ * External path (main thread can signal via postMessage).
190
+ */
191
+ handleUrgentChunk(fileType, fileId, chunkIndex) {
192
+ this.log.info('Urgent chunk request:', `${fileType}/${fileId}`, 'chunk', chunkIndex);
193
+ const acted = this.downloadManager.queue.urgentChunk(fileType, fileId, chunkIndex);
194
+ return { success: true, acted };
195
+ }
196
+
197
+ /**
198
+ * Handle DOWNLOAD_FILES with XLF-driven media resolution.
199
+ *
200
+ * Accepts { layoutOrder: number[], files: Array } from PlayerCore.
201
+ * Builds lookup maps from the flat CMS file list, fetches/parses XLFs to
202
+ * discover which media each layout needs, then enqueues per-layout chunks
203
+ * with barriers in playback order.
204
+ *
205
+ * @param {{ layoutOrder: number[], files: Array }} payload
206
+ */
207
+ async handleDownloadFiles({ layoutOrder, files }) {
208
+ const dm = this.downloadManager;
209
+ const queue = dm.queue;
210
+ let enqueuedCount = 0;
211
+ const enqueuedTasks = [];
212
+
213
+ // Build lookup maps from flat CMS file list
214
+ const xlfFiles = new Map(); // layoutId → file entry (for XLF download URL)
215
+ const resources = []; // fonts, bundle.min.js etc.
216
+ const mediaFiles = new Map(); // mediaId (string) → file entry
217
+ for (const f of files) {
218
+ if (f.type === 'layout') {
219
+ xlfFiles.set(parseInt(f.id), f);
220
+ } else if (f.type === 'resource' || f.code === 'fonts.css'
221
+ || (f.path && (f.path.includes('bundle.min') || f.path.includes('fonts')))) {
222
+ resources.push(f);
223
+ } else {
224
+ mediaFiles.set(String(f.id), f);
225
+ }
226
+ }
227
+
228
+ this.log.info(`Download: ${layoutOrder.length} layouts, ${mediaFiles.size} media, ${resources.length} resources`);
229
+
230
+ // ── Step 1: Fetch + cache + parse all XLFs directly (parallel) ──
231
+ const layoutMediaMap = new Map(); // layoutId → Set<mediaId>
232
+ const xlfPromises = [];
233
+ for (const layoutId of layoutOrder) {
234
+ const xlfFile = xlfFiles.get(layoutId);
235
+ if (!xlfFile?.path) continue;
236
+
237
+ xlfPromises.push((async () => {
238
+ const cacheKey = `${BASE}/cache/layout/${layoutId}`;
239
+ const existing = await this.cacheManager.get(cacheKey);
240
+ let xlfText;
241
+ if (existing) {
242
+ xlfText = await existing.clone().text();
243
+ } else {
244
+ const resp = await fetch(xlfFile.path);
245
+ if (!resp.ok) { this.log.warn(`XLF fetch failed: ${layoutId} (${resp.status})`); return; }
246
+ const blob = await resp.blob();
247
+ await this.cacheManager.put(cacheKey, blob, 'text/xml');
248
+ this.log.info(`Fetched + cached XLF ${layoutId} (${blob.size} bytes)`);
249
+ // Notify clients so pending layouts can clear
250
+ const clients = await self.clients.matchAll();
251
+ clients.forEach(c => c.postMessage({ type: 'FILE_CACHED', fileId: String(layoutId), fileType: 'layout', size: blob.size }));
252
+ xlfText = await blob.text();
253
+ }
254
+ layoutMediaMap.set(layoutId, extractMediaIdsFromXlf(xlfText, this.log));
255
+ })());
256
+ }
257
+ // Also fetch XLFs NOT in layoutOrder (non-scheduled layouts, e.g. default)
258
+ for (const [layoutId, xlfFile] of xlfFiles) {
259
+ if (layoutOrder.includes(layoutId)) continue;
260
+ xlfPromises.push((async () => {
261
+ const cacheKey = `${BASE}/cache/layout/${layoutId}`;
262
+ const existing = await this.cacheManager.get(cacheKey);
263
+ if (!existing && xlfFile.path) {
264
+ const resp = await fetch(xlfFile.path);
265
+ if (resp.ok) {
266
+ const blob = await resp.blob();
267
+ await this.cacheManager.put(cacheKey, blob, 'text/xml');
268
+ this.log.info(`Fetched + cached XLF ${layoutId} (non-scheduled, ${blob.size} bytes)`);
269
+ const clients = await self.clients.matchAll();
270
+ clients.forEach(c => c.postMessage({ type: 'FILE_CACHED', fileId: String(layoutId), fileType: 'layout', size: blob.size }));
271
+ const xlfText = await blob.text();
272
+ layoutMediaMap.set(layoutId, extractMediaIdsFromXlf(xlfText, this.log));
273
+ }
274
+ } else if (existing) {
275
+ const xlfText = await existing.clone().text();
276
+ layoutMediaMap.set(layoutId, extractMediaIdsFromXlf(xlfText, this.log));
277
+ }
278
+ })());
279
+ }
280
+ await Promise.allSettled(xlfPromises);
281
+ this.log.info(`Parsed ${layoutMediaMap.size} XLFs`);
282
+
283
+ // ── Step 2: Enqueue resources ──
284
+ const resourceBuilder = new LayoutTaskBuilder(queue);
285
+ for (const file of resources) {
286
+ const enqueued = await this._enqueueFile(dm, resourceBuilder, file, enqueuedTasks);
287
+ if (enqueued) enqueuedCount++;
288
+ }
289
+ const resourceTasks = await resourceBuilder.build();
290
+ if (resourceTasks.length > 0) {
291
+ resourceTasks.push(BARRIER);
292
+ queue.enqueueOrderedTasks(resourceTasks);
293
+ }
294
+
295
+ // ── Step 3: For each layout in play order, get media from XLF + enqueue ──
296
+ const claimed = new Set(); // Track media IDs already claimed by a layout
297
+
298
+ // Process scheduled layouts first (in play order), then non-scheduled
299
+ const allLayoutIds = [...layoutOrder, ...[...layoutMediaMap.keys()].filter(id => !layoutOrder.includes(id))];
300
+
301
+ for (const layoutId of allLayoutIds) {
302
+ const xlfMediaIds = layoutMediaMap.get(layoutId);
303
+ if (!xlfMediaIds) continue;
304
+
305
+ const matched = [];
306
+ for (const id of xlfMediaIds) {
307
+ if (claimed.has(id)) continue; // Already claimed by earlier layout
308
+ const file = mediaFiles.get(id);
309
+ if (file) {
310
+ matched.push(file);
311
+ claimed.add(id);
312
+ }
313
+ }
314
+ if (matched.length === 0) continue;
315
+
316
+ this.log.info(`Layout ${layoutId}: ${matched.length} media`);
317
+ matched.sort((a, b) => (a.size || 0) - (b.size || 0));
318
+ const builder = new LayoutTaskBuilder(queue);
319
+ for (const file of matched) {
320
+ const enqueued = await this._enqueueFile(dm, builder, file, enqueuedTasks);
321
+ if (enqueued) enqueuedCount++;
322
+ }
323
+ const orderedTasks = await builder.build();
324
+ if (orderedTasks.length > 0) {
325
+ orderedTasks.push(BARRIER);
326
+ queue.enqueueOrderedTasks(orderedTasks);
327
+ }
328
+ }
329
+
330
+ // Warn about unclaimed media (in CMS file list but not referenced by any XLF)
331
+ const unclaimed = [...mediaFiles.keys()].filter(id => !claimed.has(id));
332
+ if (unclaimed.length > 0) {
333
+ this.log.warn(`${unclaimed.length} media not in any XLF: ${unclaimed.join(', ')}`);
334
+ }
335
+
336
+ const activeCount = queue.running;
337
+ const queuedCount = queue.queue.length;
338
+ this.log.info('Downloads active:', activeCount, ', queued:', queuedCount);
339
+ return { success: true, enqueuedCount, activeCount, queuedCount };
340
+ }
341
+
342
+ /**
343
+ * Enqueue a single file for download (shared by phase 1 and phase 2).
344
+ * Handles cache checks, dedup, and incomplete chunked resume.
345
+ * @returns {boolean} true if file was enqueued (new download)
346
+ */
347
+ async _enqueueFile(dm, builder, file, enqueuedTasks) {
348
+ // Skip files with no path
349
+ if (!file.path || file.path === 'null' || file.path === 'undefined') {
350
+ this.log.debug('Skipping file with no path:', file.id);
351
+ return false;
352
+ }
353
+
354
+ const cacheKey = `${BASE}/cache/${file.type}/${file.id}`;
355
+
356
+ // Check if already cached (supports both whole files and chunked storage)
357
+ const fileInfo = await this.cacheManager.fileExists(cacheKey);
358
+ if (fileInfo.exists) {
359
+ // For chunked files, verify download actually completed
360
+ if (fileInfo.chunked && fileInfo.metadata && !fileInfo.metadata.complete) {
361
+ const { numChunks } = fileInfo.metadata;
362
+ const skipChunks = new Set();
363
+ for (let j = 0; j < numChunks; j++) {
364
+ const chunk = await this.cacheManager.getChunk(cacheKey, j);
365
+ if (chunk) skipChunks.add(j);
366
+ }
367
+
368
+ if (skipChunks.size === numChunks) {
369
+ this.log.info('All chunks present but metadata incomplete, marking complete:', cacheKey);
370
+ fileInfo.metadata.complete = true;
371
+ await this.cacheManager.updateMetadata(cacheKey, fileInfo.metadata);
372
+ return false;
373
+ }
374
+
375
+ this.log.info(`Incomplete chunked download: ${skipChunks.size}/${numChunks} chunks cached, resuming:`, cacheKey);
376
+ file.skipChunks = skipChunks;
377
+ } else {
378
+ this.log.debug('File already cached:', cacheKey, fileInfo.chunked ? '(chunked)' : '(whole file)');
379
+ await this.ensureStaticCacheEntry(file);
380
+ return false;
381
+ }
382
+ }
383
+
384
+ // Check if already downloading
385
+ const stableKey = `${file.type}/${file.id}`;
386
+ const activeTask = dm.getTask(stableKey);
387
+ if (activeTask) {
388
+ this.log.debug('File already downloading:', stableKey, '- skipping duplicate');
389
+ return false;
390
+ }
391
+
392
+ const fileDownload = builder.addFile(file);
393
+ // Only set up caching callback for NEW files (not deduped)
394
+ if (fileDownload.state === 'pending') {
395
+ const cachePromise = this.cacheFileAfterDownload(fileDownload, file);
396
+ enqueuedTasks.push(cachePromise);
397
+ return true;
398
+ }
399
+ return false;
400
+ }
401
+
402
+ /**
403
+ * Cache file after download completes.
404
+ * For large files (> chunkStorageThreshold): uses PROGRESSIVE caching —
405
+ * each chunk is stored to cache as soon as it downloads from the CMS,
406
+ * metadata is written after the HEAD request, and the client is notified
407
+ * after the first chunk so video playback can start immediately.
408
+ * For small files: traditional whole-file caching.
409
+ */
410
+ async cacheFileAfterDownload(task, fileInfo) {
411
+ try {
412
+ const cacheKey = `${BASE}/cache/${fileInfo.type}/${fileInfo.id}`;
413
+ const contentType = fileInfo.type === 'layout' ? 'text/xml' :
414
+ fileInfo.type === 'widget' ? 'text/html' :
415
+ 'application/octet-stream';
416
+
417
+ // Large files: progressive chunk caching (stream while downloading)
418
+ const fileSize = parseInt(fileInfo.size) || 0;
419
+ if (fileSize > this.config.chunkStorageThreshold) {
420
+ return await this._progressiveCacheFile(task, fileInfo, cacheKey, contentType, fileSize);
421
+ }
422
+
423
+ // Small files: wait for full download, then cache whole file
424
+ const blob = await task.wait();
425
+
426
+ await this.cacheManager.put(cacheKey, blob, contentType);
427
+ this.log.info('Cached after download:', cacheKey, `(${blob.size} bytes)`);
428
+
429
+ // Notify all clients that file is cached
430
+ const clients = await self.clients.matchAll();
431
+ clients.forEach(client => {
432
+ client.postMessage({
433
+ type: 'FILE_CACHED',
434
+ fileId: fileInfo.id,
435
+ fileType: fileInfo.type,
436
+ size: blob.size
437
+ });
438
+ });
439
+
440
+ // Also cache widget resources (.js, .css, fonts) for static serving
441
+ this._cacheStaticResource(fileInfo, blob);
442
+
443
+ // Now safe to remove from active — file is in cache, won't be re-enqueued
444
+ this.downloadManager.queue.removeCompleted(`${fileInfo.type}/${fileInfo.id}`);
445
+
446
+ return blob;
447
+ } catch (error) {
448
+ this.log.error('Failed to cache after download:', fileInfo.id, error);
449
+ this.downloadManager.queue.removeCompleted(`${fileInfo.type}/${fileInfo.id}`);
450
+ throw error;
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Progressive chunk caching: store each chunk to cache as it downloads.
456
+ * Video can start playing after first chunk + metadata are stored.
457
+ */
458
+ async _progressiveCacheFile(task, fileInfo, cacheKey, contentType, fileSize) {
459
+ const { chunkSize, cacheName } = this.config;
460
+ const cache = await caches.open(cacheName);
461
+ let chunksStored = 0;
462
+ let clientNotified = false;
463
+
464
+ // Compute expected chunk count from declared file size
465
+ const expectedChunks = Math.ceil(fileSize / chunkSize);
466
+ this.log.info(`Progressive download: ${cacheKey} (${formatBytes(fileSize)}, ~${expectedChunks} chunks)`);
467
+
468
+ // Store metadata NOW based on declared file size so Range requests can
469
+ // start working as soon as the first chunk lands in cache
470
+ const metadata = {
471
+ totalSize: fileSize,
472
+ chunkSize,
473
+ numChunks: expectedChunks,
474
+ contentType,
475
+ chunked: true,
476
+ complete: false,
477
+ createdAt: Date.now()
478
+ };
479
+
480
+ await cache.put(`${cacheKey}/metadata`, new Response(
481
+ JSON.stringify(metadata),
482
+ { headers: { 'Content-Type': 'application/json' } }
483
+ ));
484
+ // Also populate in-memory cache so Range requests skip Cache API lookup
485
+ this.cacheManager.metadataCache.set(cacheKey, metadata);
486
+ this.log.info('Metadata stored, ready for progressive streaming:', cacheKey);
487
+
488
+ // Hook into DownloadTask's chunk-by-chunk download.
489
+ // Each chunk gets stored to Cache API the moment it arrives from the CMS,
490
+ // so handleChunkedRangeRequest() can serve it immediately.
491
+ task.onChunkDownloaded = async (chunkIndex, chunkBlob, totalChunks) => {
492
+ // Store chunk to cache immediately
493
+ const chunkResponse = new Response(chunkBlob, {
494
+ headers: {
495
+ 'Content-Type': contentType,
496
+ 'Content-Length': chunkBlob.size,
497
+ 'X-Chunk-Index': chunkIndex,
498
+ 'X-Total-Chunks': totalChunks
499
+ }
500
+ });
501
+ await cache.put(`${cacheKey}/chunk-${chunkIndex}`, chunkResponse);
502
+ chunksStored++;
503
+
504
+ if (chunksStored % 2 === 0 || chunksStored === totalChunks) {
505
+ this.log.info(`Progressive: chunk ${chunksStored}/${totalChunks} cached for ${fileInfo.id}`);
506
+ }
507
+
508
+ // Notify client when key chunks arrive for early playback:
509
+ // - chunk 0: ftyp/mdat header (first bytes of file)
510
+ // - last chunk: moov atom (MP4 structure, needed by browser before playback)
511
+ // Download manager sends these two first (out-of-order priority).
512
+ if (!clientNotified && (chunkIndex === 0 || chunkIndex === totalChunks - 1)) {
513
+ // Only notify once both chunk 0 AND last chunk are stored
514
+ const hasChunk0 = chunkIndex === 0 || await this.cacheManager.getChunk(cacheKey, 0);
515
+ const hasLastChunk = chunkIndex === totalChunks - 1 || await this.cacheManager.getChunk(cacheKey, totalChunks - 1);
516
+
517
+ if (hasChunk0 && hasLastChunk) {
518
+ clientNotified = true;
519
+ const clients = await self.clients.matchAll();
520
+ clients.forEach(client => {
521
+ client.postMessage({
522
+ type: 'FILE_CACHED',
523
+ fileId: fileInfo.id,
524
+ fileType: fileInfo.type,
525
+ size: fileSize,
526
+ progressive: true,
527
+ chunksReady: chunksStored,
528
+ totalChunks
529
+ });
530
+ });
531
+ this.log.info('Chunk 0 + last chunk cached — client notified, early playback ready:', cacheKey);
532
+ }
533
+ }
534
+
535
+ // Update metadata with actual chunk count if it differs (edge case)
536
+ if (totalChunks !== expectedChunks) {
537
+ metadata.numChunks = totalChunks;
538
+ await cache.put(`${cacheKey}/metadata`, new Response(
539
+ JSON.stringify(metadata),
540
+ { headers: { 'Content-Type': 'application/json' } }
541
+ ));
542
+ }
543
+ };
544
+
545
+ // Wait for DownloadTask to finish (all chunks downloaded + callbacks fired).
546
+ // When onChunkDownloaded was used, task.wait() returns an empty Blob
547
+ // (data is already stored to cache chunk by chunk).
548
+ // When downloadFull was used instead (actual size < threshold), returns the full Blob.
549
+ const downloadedBlob = await task.wait();
550
+
551
+ // If the callback never fired (actual file smaller than DownloadTask's chunk
552
+ // threshold), use the already-downloaded blob instead of re-fetching.
553
+ if (chunksStored === 0) {
554
+ this.log.warn('Progressive callback never fired, falling back to putChunked:', cacheKey);
555
+
556
+ if (downloadedBlob.size > 0) {
557
+ // Full blob available from downloadFull path — cache it
558
+ await this.cacheManager.putChunked(cacheKey, downloadedBlob, contentType);
559
+ } else {
560
+ // Truly empty — should never happen, but cache whole file as safety net
561
+ await this.cacheManager.put(cacheKey, downloadedBlob, contentType);
562
+ }
563
+
564
+ // Notify client
565
+ const clients = await self.clients.matchAll();
566
+ clients.forEach(client => {
567
+ client.postMessage({
568
+ type: 'FILE_CACHED',
569
+ fileId: fileInfo.id,
570
+ fileType: fileInfo.type,
571
+ size: downloadedBlob.size || fileSize
572
+ });
573
+ });
574
+ this.downloadManager.queue.removeCompleted(`${fileInfo.type}/${fileInfo.id}`);
575
+ return downloadedBlob;
576
+ }
577
+
578
+ // URL expired mid-download: some chunks cached, but not all.
579
+ // Don't mark complete — next collection cycle resumes with fresh URLs.
580
+ if (task._urlExpired) {
581
+ this.log.warn(`URL expired mid-download, partial cache: ${cacheKey} (${chunksStored}/${expectedChunks} chunks stored)`);
582
+ this.downloadManager.queue.removeCompleted(`${fileInfo.type}/${fileInfo.id}`);
583
+ return new Blob([], { type: contentType });
584
+ }
585
+
586
+ this.log.info(`Progressive download complete: ${cacheKey} (${chunksStored} chunks stored)`);
587
+
588
+ // Mark metadata as complete — this is the commit point.
589
+ // Until this flag is set, the file is considered incomplete and will be
590
+ // resumed (not re-downloaded) on the next collection cycle.
591
+ metadata.complete = true;
592
+ await this.cacheManager.updateMetadata(cacheKey, metadata);
593
+
594
+ // Notify client with final complete state
595
+ const clients = await self.clients.matchAll();
596
+ clients.forEach(client => {
597
+ client.postMessage({
598
+ type: 'FILE_CACHED',
599
+ fileId: fileInfo.id,
600
+ fileType: fileInfo.type,
601
+ size: fileSize,
602
+ complete: true
603
+ });
604
+ });
605
+
606
+ // Remove from pending storage tracker (all chunks are already stored)
607
+ this.pendingChunkStorage.delete(cacheKey);
608
+
609
+ // Now safe to remove from active — all chunks are in cache
610
+ this.downloadManager.queue.removeCompleted(`${fileInfo.type}/${fileInfo.id}`);
611
+
612
+ return new Blob([], { type: contentType }); // Data is in cache, not in memory
613
+ }
614
+
615
+ /**
616
+ * Cache widget static resources (.js, .css, fonts) alongside the media cache
617
+ */
618
+ _cacheStaticResource(fileInfo, blob) {
619
+ const filename = fileInfo.path ? (() => {
620
+ try { return new URL(fileInfo.path).searchParams.get('file'); } catch { return null; }
621
+ })() : null;
622
+
623
+ if (filename && (filename.endsWith('.js') || filename.endsWith('.css') ||
624
+ /\.(otf|ttf|woff2?|eot|svg)$/i.test(filename))) {
625
+
626
+ // Fire-and-forget — don't block the main cache flow
627
+ (async () => {
628
+ try {
629
+ const staticCache = await caches.open(this.config.staticCache);
630
+ const staticKey = `${BASE}/cache/static/${filename}`;
631
+
632
+ const ext = filename.split('.').pop().toLowerCase();
633
+ const staticContentType = STATIC_CONTENT_TYPES[ext] || 'application/octet-stream';
634
+
635
+ await Promise.all([
636
+ staticCache.put(staticKey, new Response(blob.slice(0, blob.size, blob.type), {
637
+ headers: { 'Content-Type': staticContentType }
638
+ })),
639
+ this.cacheManager.put(staticKey, blob.slice(0, blob.size, blob.type), staticContentType)
640
+ ]);
641
+
642
+ this.log.info('Also cached as static resource:', filename, `(${staticContentType})`);
643
+ } catch (e) {
644
+ this.log.warn('Failed to cache static resource:', filename, e);
645
+ }
646
+ })();
647
+ }
648
+ }
649
+
650
+ /**
651
+ * Ensure widget resource files have static cache entries
652
+ * Handles files that were cached before the dual-cache deploy
653
+ */
654
+ async ensureStaticCacheEntry(fileInfo) {
655
+ const filename = fileInfo.path ? (() => {
656
+ try { return new URL(fileInfo.path).searchParams.get('file'); } catch { return null; }
657
+ })() : null;
658
+
659
+ if (!filename || !(filename.endsWith('.js') || filename.endsWith('.css') ||
660
+ /\.(otf|ttf|woff2?|eot|svg)$/i.test(filename))) {
661
+ return; // Not a widget resource
662
+ }
663
+
664
+ const staticCache = await caches.open(this.config.staticCache);
665
+ const staticKey = `${BASE}/cache/static/${filename}`;
666
+
667
+ // Check if already in static cache
668
+ const existing = await staticCache.match(staticKey);
669
+ if (existing) return; // Already populated
670
+
671
+ // Read from media cache and copy to static cache
672
+ const cacheKey = `${BASE}/cache/${fileInfo.type}/${fileInfo.id}`;
673
+ const cached = await this.cacheManager.get(cacheKey);
674
+ if (!cached) return;
675
+
676
+ const blob = await cached.blob();
677
+ const ext = filename.split('.').pop().toLowerCase();
678
+ const staticContentType = STATIC_CONTENT_TYPES[ext] || 'application/octet-stream';
679
+
680
+ const staticPathKey = `${BASE}/cache/static/${filename}`;
681
+
682
+ await Promise.all([
683
+ staticCache.put(staticKey, new Response(blob.slice(0, blob.size, blob.type), {
684
+ headers: { 'Content-Type': staticContentType }
685
+ })),
686
+ this.cacheManager.put(staticPathKey, blob.slice(0, blob.size, blob.type), staticContentType)
687
+ ]);
688
+
689
+ this.log.info('Backfilled static cache for:', filename, `(${staticContentType}, ${blob.size} bytes)`);
690
+ }
691
+
692
+ /**
693
+ * Handle CLEAR_CACHE message
694
+ */
695
+ async handleClearCache() {
696
+ this.log.info('Clearing cache');
697
+ await this.cacheManager.clear();
698
+ return { success: true };
699
+ }
700
+
701
+ /**
702
+ * Handle GET_DOWNLOAD_PROGRESS message
703
+ */
704
+ async handleGetProgress() {
705
+ const progress = this.downloadManager.getProgress();
706
+ return { success: true, progress };
707
+ }
708
+ }