@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.
- package/README.md +13 -44
- package/package.json +5 -3
- package/src/blob-cache.js +111 -0
- package/src/cache-manager.js +270 -0
- package/src/chunk-config.js +111 -0
- package/src/index.js +7 -23
- package/src/message-handler.js +708 -0
- package/src/request-handler.js +620 -0
- package/src/sw-utils.js +210 -0
- package/src/xlf-parser.js +21 -0
- package/src/worker.js +0 -12
|
@@ -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
|
+
}
|