@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.
- package/docs/CACHE_PROXY_ARCHITECTURE.md +439 -0
- package/docs/README.md +118 -0
- package/package.json +41 -0
- package/src/cache-proxy.js +493 -0
- package/src/cache-proxy.test.js +391 -0
- package/src/cache.js +739 -0
- package/src/cache.test.js +760 -0
- package/src/download-manager.js +434 -0
- package/src/download-manager.test.js +726 -0
- package/src/index.js +4 -0
- package/src/test-utils.js +133 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DownloadManager - Standalone file download orchestration
|
|
3
|
+
*
|
|
4
|
+
* Works in both browser and Service Worker contexts.
|
|
5
|
+
* Handles download queue, concurrency control, parallel chunks, and MD5 verification.
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* - DownloadQueue: Manages download queue with concurrency control
|
|
9
|
+
* - DownloadTask: Handles individual file download with parallel chunks
|
|
10
|
+
* - MD5Calculator: Calculates MD5 hash (optional, uses SparkMD5 if available)
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* const dm = new DownloadManager({ concurrency: 4, chunkSize: 50MB });
|
|
14
|
+
* const task = dm.enqueue({ id, type, path, md5 });
|
|
15
|
+
* const blob = await task.wait();
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const DEFAULT_CONCURRENCY = 4; // Max concurrent file downloads
|
|
19
|
+
const DEFAULT_CHUNK_SIZE = 50 * 1024 * 1024; // 50MB chunks
|
|
20
|
+
const DEFAULT_CHUNKS_PER_FILE = 4; // Parallel chunks per file
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* DownloadTask - Handles individual file download
|
|
24
|
+
*/
|
|
25
|
+
export class DownloadTask {
|
|
26
|
+
constructor(fileInfo, options = {}) {
|
|
27
|
+
this.fileInfo = fileInfo;
|
|
28
|
+
this.options = options;
|
|
29
|
+
this.downloadedBytes = 0;
|
|
30
|
+
this.totalBytes = 0;
|
|
31
|
+
this.promise = null;
|
|
32
|
+
this.resolve = null;
|
|
33
|
+
this.reject = null;
|
|
34
|
+
this.waiters = []; // Promises waiting for completion
|
|
35
|
+
this.state = 'pending'; // pending, downloading, complete, failed
|
|
36
|
+
// Progressive streaming: callback fired for each chunk as it downloads
|
|
37
|
+
// Set externally before download starts: (chunkIndex, chunkBlob, totalChunks) => Promise
|
|
38
|
+
this.onChunkDownloaded = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Wait for download to complete
|
|
43
|
+
* Returns blob when ready
|
|
44
|
+
*/
|
|
45
|
+
async wait() {
|
|
46
|
+
if (this.promise) {
|
|
47
|
+
return this.promise;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (this.state === 'complete') {
|
|
51
|
+
return this.promise;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Create waiter promise
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
this.waiters.push({ resolve, reject });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Start download with parallel chunks
|
|
62
|
+
*/
|
|
63
|
+
async start() {
|
|
64
|
+
const { id, type, path, md5 } = this.fileInfo;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
this.state = 'downloading';
|
|
68
|
+
console.log('[DownloadTask] Starting:', path);
|
|
69
|
+
|
|
70
|
+
// HEAD request to get file size
|
|
71
|
+
const headResponse = await fetch(path, { method: 'HEAD' });
|
|
72
|
+
if (!headResponse.ok) {
|
|
73
|
+
throw new Error(`HEAD request failed: ${headResponse.status}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
this.totalBytes = parseInt(headResponse.headers.get('Content-Length') || '0');
|
|
77
|
+
const contentType = headResponse.headers.get('Content-Type') || 'application/octet-stream';
|
|
78
|
+
|
|
79
|
+
console.log('[DownloadTask] File size:', (this.totalBytes / 1024 / 1024).toFixed(1), 'MB');
|
|
80
|
+
|
|
81
|
+
// Download in chunks if large file
|
|
82
|
+
let blob;
|
|
83
|
+
const chunkSize = this.options.chunkSize || DEFAULT_CHUNK_SIZE;
|
|
84
|
+
const chunksPerFile = this.options.chunksPerFile || DEFAULT_CHUNKS_PER_FILE;
|
|
85
|
+
|
|
86
|
+
if (this.totalBytes > 100 * 1024 * 1024) { // > 100MB
|
|
87
|
+
blob = await this.downloadChunks(path, contentType, chunkSize, chunksPerFile);
|
|
88
|
+
} else {
|
|
89
|
+
blob = await this.downloadFull(path);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Verify MD5 if provided and MD5 calculator available
|
|
93
|
+
if (md5 && this.options.calculateMD5) {
|
|
94
|
+
const calculatedMd5 = await this.options.calculateMD5(blob);
|
|
95
|
+
if (calculatedMd5 && calculatedMd5 !== md5) {
|
|
96
|
+
console.warn('[DownloadTask] MD5 mismatch:', path);
|
|
97
|
+
console.warn('[DownloadTask] Expected:', md5);
|
|
98
|
+
console.warn('[DownloadTask] Got:', calculatedMd5);
|
|
99
|
+
// Continue anyway (kiosk mode)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log('[DownloadTask] Complete:', path, `(${blob.size} bytes)`);
|
|
104
|
+
|
|
105
|
+
// Mark complete
|
|
106
|
+
this.state = 'complete';
|
|
107
|
+
this.blob = blob;
|
|
108
|
+
|
|
109
|
+
// Resolve all waiters
|
|
110
|
+
this.promise = Promise.resolve(blob);
|
|
111
|
+
for (const waiter of this.waiters) {
|
|
112
|
+
waiter.resolve(blob);
|
|
113
|
+
}
|
|
114
|
+
this.waiters = [];
|
|
115
|
+
|
|
116
|
+
return blob;
|
|
117
|
+
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.error('[DownloadTask] Failed:', path, error);
|
|
120
|
+
this.state = 'failed';
|
|
121
|
+
|
|
122
|
+
// Reject all waiters
|
|
123
|
+
this.promise = Promise.reject(error);
|
|
124
|
+
this.promise.catch(() => {}); // Prevent unhandled rejection if nobody calls wait()
|
|
125
|
+
for (const waiter of this.waiters) {
|
|
126
|
+
waiter.reject(error);
|
|
127
|
+
}
|
|
128
|
+
this.waiters = [];
|
|
129
|
+
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Download full file (for small files)
|
|
136
|
+
*/
|
|
137
|
+
async downloadFull(url) {
|
|
138
|
+
const response = await fetch(url);
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
throw new Error(`Download failed: ${response.status}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const blob = await response.blob();
|
|
144
|
+
this.downloadedBytes = blob.size;
|
|
145
|
+
return blob;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Download file in parallel chunks (for large files)
|
|
150
|
+
* If onChunkDownloaded callback is set, fires it for each chunk as it arrives
|
|
151
|
+
* so the caller can cache chunks progressively (enabling streaming before
|
|
152
|
+
* the entire file is downloaded).
|
|
153
|
+
*/
|
|
154
|
+
async downloadChunks(url, contentType, chunkSize, concurrentChunks) {
|
|
155
|
+
// Calculate chunk ranges
|
|
156
|
+
const chunkRanges = [];
|
|
157
|
+
for (let start = 0; start < this.totalBytes; start += chunkSize) {
|
|
158
|
+
const end = Math.min(start + chunkSize - 1, this.totalBytes - 1);
|
|
159
|
+
chunkRanges.push({ start, end, index: chunkRanges.length });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Prioritize chunk 0 (ftyp header) and last chunk (moov atom) for video early playback.
|
|
163
|
+
// Modern browsers seek to end of MP4 for moov, so having both extremes first
|
|
164
|
+
// lets video start playing while middle chunks are still downloading.
|
|
165
|
+
if (chunkRanges.length > 2) {
|
|
166
|
+
const lastChunk = chunkRanges.pop(); // remove last
|
|
167
|
+
chunkRanges.splice(1, 0, lastChunk); // insert after chunk 0
|
|
168
|
+
}
|
|
169
|
+
console.log('[DownloadTask] Downloading', chunkRanges.length, 'chunks (chunk 0 + last prioritized)');
|
|
170
|
+
|
|
171
|
+
// Download chunks in parallel with concurrency limit
|
|
172
|
+
const chunkMap = new Map();
|
|
173
|
+
let nextChunkIndex = 0;
|
|
174
|
+
|
|
175
|
+
const downloadChunk = async (range) => {
|
|
176
|
+
const rangeHeader = `bytes=${range.start}-${range.end}`;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const response = await fetch(url, {
|
|
180
|
+
headers: { 'Range': rangeHeader }
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (!response.ok && response.status !== 206) {
|
|
184
|
+
throw new Error(`Chunk ${range.index} failed: ${response.status}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const chunkBlob = await response.blob();
|
|
188
|
+
chunkMap.set(range.index, chunkBlob);
|
|
189
|
+
|
|
190
|
+
this.downloadedBytes += chunkBlob.size;
|
|
191
|
+
const progress = (this.downloadedBytes / this.totalBytes * 100).toFixed(1);
|
|
192
|
+
console.log('[DownloadTask] Chunk', range.index + 1, '/', chunkRanges.length, `(${progress}%)`);
|
|
193
|
+
|
|
194
|
+
// Progressive streaming: notify caller to cache this chunk immediately
|
|
195
|
+
if (this.onChunkDownloaded) {
|
|
196
|
+
try {
|
|
197
|
+
await this.onChunkDownloaded(range.index, chunkBlob, chunkRanges.length);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
console.warn('[DownloadTask] onChunkDownloaded callback error:', e);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Notify progress if callback provided
|
|
204
|
+
if (this.options.onProgress) {
|
|
205
|
+
this.options.onProgress(this.downloadedBytes, this.totalBytes);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return chunkBlob;
|
|
209
|
+
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error('[DownloadTask] Chunk', range.index, 'failed:', error);
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Download with concurrency control
|
|
217
|
+
const downloadNext = async () => {
|
|
218
|
+
while (nextChunkIndex < chunkRanges.length) {
|
|
219
|
+
const range = chunkRanges[nextChunkIndex++];
|
|
220
|
+
await downloadChunk(range);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Start concurrent downloaders
|
|
225
|
+
const downloaders = [];
|
|
226
|
+
for (let i = 0; i < concurrentChunks; i++) {
|
|
227
|
+
downloaders.push(downloadNext());
|
|
228
|
+
}
|
|
229
|
+
await Promise.all(downloaders);
|
|
230
|
+
|
|
231
|
+
// If progressive caching was used, skip reassembly (chunks are already cached)
|
|
232
|
+
if (this.onChunkDownloaded) {
|
|
233
|
+
// Return a lightweight marker — the real data is already in cache
|
|
234
|
+
return new Blob([], { type: contentType });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Reassemble chunks in order (traditional path for small chunked downloads)
|
|
238
|
+
const orderedChunks = [];
|
|
239
|
+
for (let i = 0; i < chunkRanges.length; i++) {
|
|
240
|
+
orderedChunks.push(chunkMap.get(i));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return new Blob(orderedChunks, { type: contentType });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* DownloadQueue - Manages download queue with concurrency control
|
|
249
|
+
*/
|
|
250
|
+
export class DownloadQueue {
|
|
251
|
+
constructor(options = {}) {
|
|
252
|
+
this.concurrency = options.concurrency || DEFAULT_CONCURRENCY;
|
|
253
|
+
this.chunkSize = options.chunkSize || DEFAULT_CHUNK_SIZE;
|
|
254
|
+
this.chunksPerFile = options.chunksPerFile || DEFAULT_CHUNKS_PER_FILE;
|
|
255
|
+
this.calculateMD5 = options.calculateMD5; // Optional MD5 calculator function
|
|
256
|
+
this.onProgress = options.onProgress; // Optional progress callback
|
|
257
|
+
|
|
258
|
+
this.queue = [];
|
|
259
|
+
this.active = new Map(); // url -> DownloadTask
|
|
260
|
+
this.running = 0;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Add file to download queue
|
|
265
|
+
* Returns existing task if already downloading
|
|
266
|
+
*/
|
|
267
|
+
enqueue(fileInfo) {
|
|
268
|
+
const { path } = fileInfo;
|
|
269
|
+
|
|
270
|
+
// If already downloading, return existing task
|
|
271
|
+
if (this.active.has(path)) {
|
|
272
|
+
console.log('[DownloadQueue] File already downloading:', path);
|
|
273
|
+
return this.active.get(path);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Create new download task
|
|
277
|
+
const task = new DownloadTask(fileInfo, {
|
|
278
|
+
chunkSize: this.chunkSize,
|
|
279
|
+
chunksPerFile: this.chunksPerFile,
|
|
280
|
+
calculateMD5: this.calculateMD5,
|
|
281
|
+
onProgress: this.onProgress
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
this.active.set(path, task);
|
|
285
|
+
this.queue.push(task);
|
|
286
|
+
|
|
287
|
+
console.log('[DownloadQueue] Enqueued:', path, `(${this.queue.length} pending, ${this.running} active)`);
|
|
288
|
+
|
|
289
|
+
// Start download if capacity available
|
|
290
|
+
this.processQueue();
|
|
291
|
+
|
|
292
|
+
return task;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Process queue - start downloads up to concurrency limit
|
|
297
|
+
*/
|
|
298
|
+
async processQueue() {
|
|
299
|
+
console.log('[DownloadQueue] processQueue:', this.running, 'running,', this.queue.length, 'queued');
|
|
300
|
+
|
|
301
|
+
while (this.running < this.concurrency && this.queue.length > 0) {
|
|
302
|
+
const task = this.queue.shift();
|
|
303
|
+
this.running++;
|
|
304
|
+
|
|
305
|
+
console.log('[DownloadQueue] Starting:', task.fileInfo.path, `(${this.running}/${this.concurrency} active)`);
|
|
306
|
+
|
|
307
|
+
// Start download (don't await - let it run in background)
|
|
308
|
+
// .catch is safe here: errors are already propagated to waiters inside start()
|
|
309
|
+
task.start()
|
|
310
|
+
.catch(() => {}) // Suppress — error handled internally via waiters
|
|
311
|
+
.finally(() => {
|
|
312
|
+
this.running--;
|
|
313
|
+
this.active.delete(task.fileInfo.path);
|
|
314
|
+
console.log('[DownloadQueue] Complete:', task.fileInfo.path, `(${this.running} active, ${this.queue.length} pending)`);
|
|
315
|
+
|
|
316
|
+
// Process next in queue
|
|
317
|
+
this.processQueue();
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (this.running >= this.concurrency) {
|
|
322
|
+
console.log('[DownloadQueue] Concurrency limit reached:', this.running, '/', this.concurrency);
|
|
323
|
+
}
|
|
324
|
+
if (this.queue.length === 0 && this.running === 0) {
|
|
325
|
+
console.log('[DownloadQueue] All downloads complete');
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Move a file to the front of the queue (if still queued, not yet started)
|
|
331
|
+
* @param {string} fileType - 'media' or 'layout'
|
|
332
|
+
* @param {string} fileId - File ID
|
|
333
|
+
* @returns {boolean} true if file was found (queued or active)
|
|
334
|
+
*/
|
|
335
|
+
prioritize(fileType, fileId) {
|
|
336
|
+
const idx = this.queue.findIndex(task =>
|
|
337
|
+
task.fileInfo.type === fileType && String(task.fileInfo.id) === String(fileId)
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
if (idx > 0) {
|
|
341
|
+
const [task] = this.queue.splice(idx, 1);
|
|
342
|
+
this.queue.unshift(task);
|
|
343
|
+
console.log('[DownloadQueue] Prioritized:', `${fileType}/${fileId}`, '(moved to front of queue)');
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (idx === 0) {
|
|
348
|
+
console.log('[DownloadQueue] Already at front:', `${fileType}/${fileId}`);
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Check if already downloading
|
|
353
|
+
for (const [, task] of this.active) {
|
|
354
|
+
if (task.fileInfo.type === fileType && String(task.fileInfo.id) === String(fileId)) {
|
|
355
|
+
console.log('[DownloadQueue] Already downloading:', `${fileType}/${fileId}`);
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
console.log('[DownloadQueue] Not found in queue:', `${fileType}/${fileId}`);
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Get task by URL (returns null if not downloading)
|
|
366
|
+
*/
|
|
367
|
+
getTask(url) {
|
|
368
|
+
return this.active.get(url) || null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Get progress for all active downloads
|
|
373
|
+
*/
|
|
374
|
+
getProgress() {
|
|
375
|
+
const progress = {};
|
|
376
|
+
for (const [url, task] of this.active.entries()) {
|
|
377
|
+
progress[url] = {
|
|
378
|
+
downloaded: task.downloadedBytes,
|
|
379
|
+
total: task.totalBytes,
|
|
380
|
+
percent: task.totalBytes > 0 ? (task.downloadedBytes / task.totalBytes * 100).toFixed(1) : 0,
|
|
381
|
+
state: task.state
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
return progress;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Cancel all downloads
|
|
389
|
+
*/
|
|
390
|
+
clear() {
|
|
391
|
+
this.queue = [];
|
|
392
|
+
this.active.clear();
|
|
393
|
+
this.running = 0;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* DownloadManager - Main API
|
|
399
|
+
*/
|
|
400
|
+
export class DownloadManager {
|
|
401
|
+
constructor(options = {}) {
|
|
402
|
+
this.queue = new DownloadQueue(options);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Enqueue file for download
|
|
407
|
+
* @param {Object} fileInfo - { id, type, path, md5 }
|
|
408
|
+
* @returns {DownloadTask}
|
|
409
|
+
*/
|
|
410
|
+
enqueue(fileInfo) {
|
|
411
|
+
return this.queue.enqueue(fileInfo);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Get download task by URL
|
|
416
|
+
*/
|
|
417
|
+
getTask(url) {
|
|
418
|
+
return this.queue.getTask(url);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Get progress for all downloads
|
|
423
|
+
*/
|
|
424
|
+
getProgress() {
|
|
425
|
+
return this.queue.getProgress();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Clear all downloads
|
|
430
|
+
*/
|
|
431
|
+
clear() {
|
|
432
|
+
this.queue.clear();
|
|
433
|
+
}
|
|
434
|
+
}
|