@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,620 @@
1
+ /**
2
+ * RequestHandler - Handles fetch events for cached media
3
+ *
4
+ * Routes requests to appropriate handlers based on:
5
+ * - Storage format (whole file vs chunked)
6
+ * - Request type (GET, HEAD, Range)
7
+ */
8
+
9
+ import {
10
+ formatBytes,
11
+ parseRangeHeader,
12
+ getChunksForRange,
13
+ extractRangeFromChunks,
14
+ BASE
15
+ } from './sw-utils.js';
16
+ import { SWLogger } from './chunk-config.js';
17
+
18
+ export class RequestHandler {
19
+ /**
20
+ * @param {Object} downloadManager - DownloadManager instance
21
+ * @param {import('./cache-manager.js').CacheManager} cacheManager
22
+ * @param {import('./blob-cache.js').BlobCache} blobCache
23
+ * @param {Object} [options]
24
+ * @param {string} [options.staticCache='xibo-static-v1'] - Static cache name
25
+ */
26
+ constructor(downloadManager, cacheManager, blobCache, { staticCache = 'xibo-static-v1' } = {}) {
27
+ this.downloadManager = downloadManager;
28
+ this.cacheManager = cacheManager;
29
+ this.blobCache = blobCache;
30
+ this.staticCache = staticCache;
31
+ this.pendingFetches = new Map(); // filename → Promise<Response> for deduplication
32
+ this.log = new SWLogger('SW');
33
+
34
+ // Pending chunk blob loads: chunkKey → Promise<Blob>
35
+ // Coalesces concurrent reads for the same chunk into a single Cache API operation
36
+ this.pendingChunkLoads = new Map();
37
+ }
38
+
39
+ /**
40
+ * Route file request to appropriate handler based on storage format
41
+ * Single source of truth for format detection and handler selection
42
+ *
43
+ * @param {string} cacheKey - Cache key (e.g., /player/pwa/cache/media/6)
44
+ * @param {string} method - HTTP method ('GET' or 'HEAD')
45
+ * @param {string|null} rangeHeader - Range header value or null
46
+ * @returns {Promise<{found: boolean, handler: string, data: Object}>}
47
+ */
48
+ async routeFileRequest(cacheKey, method, rangeHeader) {
49
+ // Check file existence and format (centralized API)
50
+ const fileInfo = await this.cacheManager.fileExists(cacheKey);
51
+
52
+ if (!fileInfo.exists) {
53
+ return { found: false, handler: null, data: null };
54
+ }
55
+
56
+ // Route based on storage format and request type
57
+ if (fileInfo.chunked) {
58
+ // Chunked storage routing
59
+ const data = { metadata: fileInfo.metadata, cacheKey };
60
+
61
+ if (method === 'HEAD') {
62
+ return { found: true, handler: 'head-chunked', data };
63
+ }
64
+ if (rangeHeader) {
65
+ return { found: true, handler: 'range-chunked', data: { ...data, rangeHeader } };
66
+ }
67
+ // GET without Range - serve full file from chunks
68
+ return { found: true, handler: 'full-chunked', data };
69
+
70
+ } else {
71
+ // Whole file storage routing
72
+ const cached = await this.cacheManager.get(cacheKey);
73
+ const data = { cached, cacheKey };
74
+
75
+ if (method === 'HEAD') {
76
+ return { found: true, handler: 'head-whole', data };
77
+ }
78
+ if (rangeHeader) {
79
+ return { found: true, handler: 'range-whole', data: { ...data, rangeHeader } };
80
+ }
81
+ // GET without Range - serve whole file
82
+ return { found: true, handler: 'full-whole', data };
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Handle fetch request
88
+ * - Serve from cache if available
89
+ * - Wait for download if in progress
90
+ * - Return 404 if not cached and not downloading
91
+ */
92
+ async handleRequest(event) {
93
+ const url = new URL(event.request.url);
94
+ this.log.info('handleRequest called for:', url.href);
95
+ this.log.info('pathname:', url.pathname);
96
+
97
+ // Handle static files (player pages)
98
+ if (url.pathname === BASE + '/' ||
99
+ url.pathname === BASE + '/index.html' ||
100
+ url.pathname === BASE + '/setup.html') {
101
+ const cache = await caches.open(this.staticCache);
102
+ const cached = await cache.match(event.request);
103
+ if (cached) {
104
+ this.log.info('Serving static file from cache:', url.pathname);
105
+ return cached;
106
+ }
107
+ // Fallback to network
108
+ this.log.info('Fetching static file from network:', url.pathname);
109
+ return fetch(event.request);
110
+ }
111
+
112
+ // Handle widget resources (bundle.min.js, fonts)
113
+ // Uses pendingFetches for deduplication — concurrent requests share one fetch
114
+ if (url.pathname.includes('xmds.php') &&
115
+ (url.searchParams.get('fileType') === 'bundle' ||
116
+ url.searchParams.get('fileType') === 'fontCss' ||
117
+ url.searchParams.get('fileType') === 'font')) {
118
+ const filename = url.searchParams.get('file');
119
+ const cacheKey = `${BASE}/cache/static/${filename}`;
120
+ const cache = await caches.open(this.staticCache);
121
+
122
+ const cached = await cache.match(cacheKey);
123
+ if (cached) {
124
+ this.log.info('Serving widget resource from cache:', filename);
125
+ return cached.clone();
126
+ }
127
+
128
+ // Check if another request is already fetching this resource
129
+ if (this.pendingFetches.has(filename)) {
130
+ this.log.info('Deduplicating widget resource fetch:', filename);
131
+ const pending = await this.pendingFetches.get(filename);
132
+ return pending.clone();
133
+ }
134
+
135
+ // Fetch from CMS with deduplication
136
+ this.log.info('Fetching widget resource from CMS:', filename);
137
+ const fetchPromise = (async () => {
138
+ try {
139
+ const response = await fetch(event.request);
140
+
141
+ if (response.ok) {
142
+ this.log.info('Caching widget resource:', filename, `(${response.headers.get('Content-Type')})`);
143
+ const responseClone = response.clone();
144
+ // AWAIT cache.put to prevent race condition
145
+ await cache.put(cacheKey, responseClone);
146
+ return response;
147
+ } else {
148
+ this.log.warn('Widget resource not available (', response.status, '):', filename, '- NOT caching');
149
+ return response;
150
+ }
151
+ } catch (error) {
152
+ this.log.error('Failed to fetch widget resource:', filename, error);
153
+ return new Response('Failed to fetch widget resource', {
154
+ status: 502,
155
+ statusText: 'Bad Gateway',
156
+ headers: { 'Content-Type': 'text/plain' }
157
+ });
158
+ }
159
+ })();
160
+
161
+ this.pendingFetches.set(filename, fetchPromise);
162
+ try {
163
+ const response = await fetchPromise;
164
+ return response.clone();
165
+ } finally {
166
+ this.pendingFetches.delete(filename);
167
+ }
168
+ }
169
+
170
+ // Handle XMDS media requests (XLR compatibility)
171
+ if (url.pathname.includes('xmds.php') && url.searchParams.has('file')) {
172
+ const filename = url.searchParams.get('file');
173
+ const fileId = filename.split('.')[0];
174
+ const fileType = url.searchParams.get('type');
175
+ const cacheType = fileType === 'L' ? 'layout' : 'media';
176
+
177
+ this.log.info('XMDS request:', filename, 'type:', fileType, '→', BASE + '/cache/' + cacheType + '/' + fileId);
178
+
179
+ const cacheKey = `${BASE}/cache/${cacheType}/${fileId}`;
180
+ const cached = await this.cacheManager.get(cacheKey);
181
+ if (cached) {
182
+ // Clone the response to avoid consuming the body
183
+ return new Response(cached.clone().body, {
184
+ headers: {
185
+ 'Content-Type': cached.headers.get('Content-Type') || 'video/mp4',
186
+ 'Access-Control-Allow-Origin': '*',
187
+ 'Cache-Control': 'public, max-age=31536000',
188
+ 'Accept-Ranges': 'bytes'
189
+ }
190
+ });
191
+ }
192
+
193
+ // Not cached - pass through to CMS
194
+ this.log.info('XMDS file not cached, passing through:', filename);
195
+ return fetch(event.request);
196
+ }
197
+
198
+ // Handle static widget resources (rewritten URLs from widget HTML)
199
+ // These are absolute CMS URLs rewritten to /player/pwa/cache/static/<filename>
200
+ if (url.pathname.startsWith(BASE + '/cache/static/')) {
201
+ const filename = url.pathname.split('/').pop();
202
+ this.log.info('Static resource request:', filename);
203
+
204
+ // Try xibo-static-v1 first
205
+ const staticCache = await caches.open(this.staticCache);
206
+ const staticCached = await staticCache.match(`${BASE}/cache/static/${filename}`);
207
+ if (staticCached) {
208
+ this.log.info('Serving static resource from static cache:', filename);
209
+ return staticCached.clone();
210
+ }
211
+
212
+ // Try xibo-media-v1 at the static path (dual-cached from download manager)
213
+ const mediaCached = await this.cacheManager.get(url.pathname);
214
+ if (mediaCached) {
215
+ this.log.info('Serving static resource from media cache:', filename);
216
+ return new Response(mediaCached.clone().body, {
217
+ headers: {
218
+ 'Content-Type': mediaCached.headers.get('Content-Type') || 'application/octet-stream',
219
+ 'Access-Control-Allow-Origin': '*',
220
+ 'Cache-Control': 'public, max-age=31536000'
221
+ }
222
+ });
223
+ }
224
+
225
+ // Not cached yet — return 404 (SW widget-resource fetch will cache it on first CMS hit)
226
+ this.log.warn('Static resource not cached:', filename);
227
+ return new Response('Resource not cached', { status: 404 });
228
+ }
229
+
230
+ // Only handle /player/pwa/cache/* requests below
231
+ if (!url.pathname.startsWith(BASE + '/cache/')) {
232
+ this.log.info('NOT a cache request, returning null:', url.pathname);
233
+ return null; // Let browser handle
234
+ }
235
+
236
+ this.log.info('IS a cache request, proceeding...', url.pathname);
237
+
238
+ // Handle widget HTML requests
239
+ if (url.pathname.startsWith(BASE + '/cache/widget/')) {
240
+ this.log.info('Widget HTML request:', url.pathname);
241
+ const cached = await this.cacheManager.get(url.pathname);
242
+ if (cached) {
243
+ return new Response(cached.clone().body, {
244
+ headers: {
245
+ 'Content-Type': 'text/html; charset=utf-8',
246
+ 'Access-Control-Allow-Origin': '*',
247
+ 'Cache-Control': 'public, max-age=31536000'
248
+ }
249
+ });
250
+ }
251
+ return new Response('<!DOCTYPE html><html><body>Widget not found</body></html>', {
252
+ status: 404,
253
+ headers: { 'Content-Type': 'text/html' }
254
+ });
255
+ }
256
+
257
+ // Extract cache key: already in correct format /player/pwa/cache/media/123
258
+ const cacheKey = url.pathname;
259
+ const method = event.request.method;
260
+ const rangeHeader = event.request.headers.get('Range');
261
+
262
+ this.log.debug('Request URL:', url.pathname);
263
+ this.log.debug('Cache key:', cacheKey);
264
+ if (rangeHeader) {
265
+ this.log.info(method, cacheKey, `Range: ${rangeHeader}`);
266
+ } else {
267
+ this.log.info(method, cacheKey);
268
+ }
269
+
270
+ // Use routing helper to determine how to serve this file
271
+ const route = await this.routeFileRequest(cacheKey, method, rangeHeader);
272
+
273
+ // If file exists, dispatch to appropriate handler
274
+ if (route.found) {
275
+ switch (route.handler) {
276
+ case 'head-whole':
277
+ return this.handleHeadWhole(route.data.cached?.headers.get('Content-Length'));
278
+
279
+ case 'head-chunked':
280
+ return this.handleHeadChunked(route.data.metadata, route.data.cacheKey);
281
+
282
+ case 'range-whole':
283
+ return this.handleRangeRequest(route.data.cached, route.data.rangeHeader, route.data.cacheKey);
284
+
285
+ case 'range-chunked':
286
+ return this.handleChunkedRangeRequest(route.data.cacheKey, route.data.rangeHeader, route.data.metadata);
287
+
288
+ case 'full-whole':
289
+ return this.handleFullWhole(route.data.cached, route.data.cacheKey);
290
+
291
+ case 'full-chunked':
292
+ return this.handleFullChunked(route.data.cacheKey, route.data.metadata);
293
+
294
+ default:
295
+ this.log.error('Unknown handler:', route.handler);
296
+ return new Response('Internal error: unknown handler', { status: 500 });
297
+ }
298
+ }
299
+
300
+ // File not found - check if download in progress
301
+ const parts = cacheKey.split('/');
302
+ const type = parts[2]; // 'media' or 'layout'
303
+ const id = parts[3];
304
+
305
+ let task = null;
306
+ for (const [downloadUrl, activeTask] of this.downloadManager.queue.active.entries()) {
307
+ if (activeTask.fileInfo.type === type && activeTask.fileInfo.id === id) {
308
+ task = activeTask;
309
+ break;
310
+ }
311
+ }
312
+
313
+ if (task) {
314
+ this.log.info('Download in progress, waiting:', cacheKey);
315
+
316
+ try {
317
+ await task.wait();
318
+
319
+ // After download, re-route to serve the file
320
+ const retryRoute = await this.routeFileRequest(cacheKey, method, rangeHeader);
321
+ if (retryRoute.found) {
322
+ this.log.info('Download complete, serving via', retryRoute.handler);
323
+
324
+ switch (retryRoute.handler) {
325
+ case 'full-whole':
326
+ return this.handleFullWhole(retryRoute.data.cached, retryRoute.data.cacheKey);
327
+ case 'full-chunked':
328
+ return this.handleFullChunked(retryRoute.data.cacheKey, retryRoute.data.metadata);
329
+ default:
330
+ // For Range/HEAD after download, fall through to normal routing
331
+ return this.handleRequest(event); // Recursive call with fresh state
332
+ }
333
+ }
334
+ } catch (error) {
335
+ this.log.error('Download failed:', cacheKey, error);
336
+ return new Response('Download failed: ' + error.message, { status: 500 });
337
+ }
338
+ }
339
+
340
+ // Not cached and not downloading - return 404
341
+ this.log.info('Not found:', cacheKey);
342
+ return new Response('Not found', { status: 404 });
343
+ }
344
+
345
+ /**
346
+ * Handle HEAD request for whole file
347
+ */
348
+ handleHeadWhole(size) {
349
+ this.log.info('HEAD response: File exists (whole file)');
350
+ return new Response(null, {
351
+ status: 200,
352
+ headers: {
353
+ 'Content-Length': size ? size.toString() : '',
354
+ 'Accept-Ranges': 'bytes',
355
+ 'Access-Control-Allow-Origin': '*'
356
+ }
357
+ });
358
+ }
359
+
360
+ /**
361
+ * Handle HEAD request for chunked file.
362
+ * Only reports 200 if chunk 0 is actually in cache (not just metadata).
363
+ * Metadata-only means the progressive download has started but no data
364
+ * is servable yet — the client should treat this as "not ready".
365
+ */
366
+ async handleHeadChunked(metadata, cacheKey) {
367
+ const chunk0 = await this.cacheManager.getChunk(cacheKey, 0);
368
+ if (!chunk0) {
369
+ this.log.info('HEAD response: Chunked file not yet playable (chunk 0 missing):', cacheKey);
370
+ return new Response(null, { status: 404 });
371
+ }
372
+ this.log.info('HEAD response: File exists (chunked)');
373
+ return new Response(null, {
374
+ status: 200,
375
+ headers: {
376
+ 'Content-Length': metadata.totalSize.toString(),
377
+ 'Accept-Ranges': 'bytes',
378
+ 'Access-Control-Allow-Origin': '*'
379
+ }
380
+ });
381
+ }
382
+
383
+ /**
384
+ * Handle full GET request for whole file (no Range)
385
+ */
386
+ handleFullWhole(cached, cacheKey) {
387
+ const contentLength = cached.headers.get('Content-Length');
388
+ const fileSize = contentLength ? formatBytes(parseInt(contentLength)) : 'unknown size';
389
+ this.log.info('Serving from cache:', cacheKey, `(${fileSize})`);
390
+
391
+ return new Response(cached.body, {
392
+ headers: {
393
+ 'Content-Type': cached.headers.get('Content-Type') || 'application/octet-stream',
394
+ 'Content-Length': contentLength || '',
395
+ 'Accept-Ranges': 'bytes',
396
+ 'Access-Control-Allow-Origin': '*',
397
+ 'Cache-Control': 'public, max-age=31536000'
398
+ }
399
+ });
400
+ }
401
+
402
+ /**
403
+ * Handle full GET request for chunked file (no Range) - serve entire file as chunks
404
+ */
405
+ async handleFullChunked(cacheKey, metadata) {
406
+ this.log.info('Chunked file GET without Range:', cacheKey, `- serving full file from ${metadata.numChunks} chunks`);
407
+
408
+ // Serve entire file using synthetic range
409
+ const syntheticRange = `bytes=0-${metadata.totalSize - 1}`;
410
+ return this.handleChunkedRangeRequest(cacheKey, syntheticRange, metadata);
411
+ }
412
+
413
+ /**
414
+ * Handle Range request for video seeking with blob caching
415
+ * @param {Response} cachedResponse - Cached response from Cache API
416
+ * @param {string} rangeHeader - Range header value (e.g., "bytes=0-1000")
417
+ * @param {string} cacheKey - Cache key for blob cache lookup
418
+ */
419
+ async handleRangeRequest(cachedResponse, rangeHeader, cacheKey) {
420
+ // Use blob cache to avoid re-materializing on every seek
421
+ const blob = await this.blobCache.get(cacheKey, async () => {
422
+ const cachedClone = cachedResponse.clone();
423
+ return await cachedClone.blob();
424
+ });
425
+
426
+ const fileSize = blob.size;
427
+
428
+ // Parse Range header using utility
429
+ const { start, end } = parseRangeHeader(rangeHeader, fileSize);
430
+
431
+ // Extract requested range (blob.slice is lazy - no copy!)
432
+ const rangeBlob = blob.slice(start, end + 1);
433
+
434
+ this.log.debug(`Range: bytes ${start}-${end}/${fileSize} (${formatBytes(rangeBlob.size)} of ${formatBytes(fileSize)})`);
435
+
436
+ return new Response(rangeBlob, {
437
+ status: 206,
438
+ statusText: 'Partial Content',
439
+ headers: {
440
+ 'Content-Type': cachedResponse.headers.get('Content-Type') || 'video/mp4',
441
+ 'Content-Length': rangeBlob.size.toString(),
442
+ 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
443
+ 'Accept-Ranges': 'bytes',
444
+ 'Access-Control-Allow-Origin': '*'
445
+ }
446
+ });
447
+ }
448
+
449
+ /**
450
+ * Handle Range request for chunked files (load only required chunks)
451
+ * @param {string} cacheKey - Base cache key
452
+ * @param {string} rangeHeader - Range header
453
+ * @param {Object} metadata - Chunk metadata
454
+ */
455
+ async handleChunkedRangeRequest(cacheKey, rangeHeader, metadata) {
456
+ const { totalSize, chunkSize, numChunks, contentType } = metadata;
457
+
458
+ // Parse Range header using utility
459
+ const { start, end: parsedEnd } = parseRangeHeader(rangeHeader, totalSize);
460
+
461
+ // Cap open-ended ranges (e.g., "bytes=0-") to a single chunk for progressive streaming,
462
+ // but ONLY if some chunks are still missing. When all chunks from the start position
463
+ // to the end of file are already cached, serve the full range to avoid sequential
464
+ // chunk-by-chunk round-trips that can cause video stalls.
465
+ let end = parsedEnd;
466
+ const rangeStr = rangeHeader.replace(/bytes=/, '');
467
+ const isOpenEnded = rangeStr.indexOf('-') === rangeStr.length - 1;
468
+ if (isOpenEnded) {
469
+ const startChunkIdx = Math.floor(start / chunkSize);
470
+ const lastChunkIdx = numChunks - 1;
471
+
472
+ // Check if all remaining chunks are cached (quick BlobCache check first, then Cache API)
473
+ let allCached = true;
474
+ for (let i = startChunkIdx; i <= lastChunkIdx; i++) {
475
+ const chunkKey = `${cacheKey}/chunk-${i}`;
476
+ if (this.blobCache.has(chunkKey)) continue;
477
+ // Not in BlobCache — check Cache API
478
+ const resp = await this.cacheManager.getChunk(cacheKey, i);
479
+ if (!resp) {
480
+ allCached = false;
481
+ break;
482
+ }
483
+ }
484
+
485
+ if (!allCached) {
486
+ const cappedEnd = Math.min((startChunkIdx + 1) * chunkSize - 1, totalSize - 1);
487
+ if (cappedEnd < end) {
488
+ end = cappedEnd;
489
+ this.log.info(`Progressive streaming: capping bytes=${start}- to chunk ${startChunkIdx} (bytes ${start}-${end}/${totalSize})`);
490
+ }
491
+ } else {
492
+ this.log.info(`All chunks cached from ${startChunkIdx} to ${lastChunkIdx}, serving full range (bytes ${start}-${end}/${totalSize})`);
493
+ }
494
+ }
495
+
496
+ // Calculate which chunks contain the requested range using utility
497
+ const { startChunk, endChunk, count: chunksNeeded } = getChunksForRange(start, end, chunkSize);
498
+
499
+ this.log.debug(`Chunked range: bytes ${start}-${end}/${totalSize} (chunks ${startChunk}-${endChunk}, ${chunksNeeded} chunks)`);
500
+
501
+ // Load a single chunk, with coalescing + blob caching.
502
+ // Returns the blob immediately if cached, or polls until available.
503
+ const loadChunk = (i) => {
504
+ const chunkKey = `${cacheKey}/chunk-${i}`;
505
+
506
+ return this.blobCache.get(chunkKey, () => {
507
+ // Coalesce: reuse in-flight Cache API read if another request is
508
+ // already loading this exact chunk
509
+ if (this.pendingChunkLoads.has(chunkKey)) {
510
+ return this.pendingChunkLoads.get(chunkKey);
511
+ }
512
+
513
+ const loadPromise = (async () => {
514
+ let chunkResponse = await this.cacheManager.getChunk(cacheKey, i);
515
+ if (chunkResponse) return await chunkResponse.blob();
516
+
517
+ // Chunk not yet stored — progressive download still running.
518
+ // Signal emergency priority: video is stalled waiting for this chunk.
519
+ // Moves it to front of queue with exclusive bandwidth.
520
+ this.log.info(`Chunk ${i}/${numChunks} not yet available for ${cacheKey}, signalling urgent...`);
521
+ {
522
+ const keyParts = cacheKey.split('/');
523
+ const urgentFileId = keyParts[keyParts.length - 1];
524
+ const urgentFileType = keyParts[keyParts.length - 2];
525
+ this.downloadManager.queue.urgentChunk(urgentFileType, urgentFileId, i);
526
+ }
527
+
528
+ // Poll with increasing backoff: 60 × 1s = 60s max wait.
529
+ for (let retry = 0; retry < 60; retry++) {
530
+ await new Promise(resolve => setTimeout(resolve, 1000));
531
+ chunkResponse = await this.cacheManager.getChunk(cacheKey, i);
532
+ if (chunkResponse) {
533
+ this.log.info(`Chunk ${i}/${numChunks} arrived for ${cacheKey} after ${retry + 1}s`);
534
+ return await chunkResponse.blob();
535
+ }
536
+ }
537
+ throw new Error(`Chunk ${i} not available for ${cacheKey} after 60s`);
538
+ })();
539
+
540
+ this.pendingChunkLoads.set(chunkKey, loadPromise);
541
+ loadPromise.finally(() => this.pendingChunkLoads.delete(chunkKey));
542
+ return loadPromise;
543
+ });
544
+ };
545
+
546
+ // Fast path: try to load all chunks immediately (no waiting).
547
+ // If all are cached, serve the blob response synchronously.
548
+ const immediateBlobs = [];
549
+ let allImmediate = true;
550
+ for (let i = startChunk; i <= endChunk; i++) {
551
+ const chunkResponse = await this.cacheManager.getChunk(cacheKey, i);
552
+ if (chunkResponse) {
553
+ const chunkKey = `${cacheKey}/chunk-${i}`;
554
+ const blob = await this.blobCache.get(chunkKey, async () => await chunkResponse.blob());
555
+ immediateBlobs.push(blob);
556
+ } else {
557
+ allImmediate = false;
558
+ break;
559
+ }
560
+ }
561
+
562
+ if (allImmediate && immediateBlobs.length === chunksNeeded) {
563
+ // All chunks available — serve immediately (common path for completed downloads)
564
+ const rangeData = extractRangeFromChunks(immediateBlobs, start, end, chunkSize);
565
+ this.log.debug(`Serving chunked range: ${formatBytes(rangeData.size)} from ${chunksNeeded} chunk(s)`);
566
+
567
+ return new Response(rangeData, {
568
+ status: 206,
569
+ statusText: 'Partial Content',
570
+ headers: {
571
+ 'Content-Type': contentType,
572
+ 'Content-Length': rangeData.size.toString(),
573
+ 'Content-Range': `bytes ${start}-${end}/${totalSize}`,
574
+ 'Accept-Ranges': 'bytes',
575
+ 'Access-Control-Allow-Origin': '*'
576
+ }
577
+ });
578
+ }
579
+
580
+ // Slow path: some chunks still downloading. Return a 206 with a
581
+ // ReadableStream body so Chrome sees a "slow" response (buffering
582
+ // spinner) instead of an error. The stream pushes data as chunks arrive.
583
+ this.log.info(`Streaming response for ${cacheKey} bytes ${start}-${end} (waiting for chunks)`);
584
+ const rangeSize = end - start + 1;
585
+
586
+ const stream = new ReadableStream({
587
+ async start(controller) {
588
+ try {
589
+ // Load all required chunks (with waiting for missing ones)
590
+ const chunkBlobs = [];
591
+ for (let i = startChunk; i <= endChunk; i++) {
592
+ const blob = await loadChunk(i);
593
+ chunkBlobs.push(blob);
594
+ }
595
+
596
+ // Extract the exact byte range and push to stream
597
+ const rangeData = extractRangeFromChunks(chunkBlobs, start, end, chunkSize);
598
+ const buffer = await rangeData.arrayBuffer();
599
+ controller.enqueue(new Uint8Array(buffer));
600
+ controller.close();
601
+ } catch (err) {
602
+ this.log.error(`Stream error for ${cacheKey}: ${err.message}`);
603
+ controller.error(err);
604
+ }
605
+ }
606
+ });
607
+
608
+ return new Response(stream, {
609
+ status: 206,
610
+ statusText: 'Partial Content',
611
+ headers: {
612
+ 'Content-Type': contentType,
613
+ 'Content-Length': rangeSize.toString(),
614
+ 'Content-Range': `bytes ${start}-${end}/${totalSize}`,
615
+ 'Accept-Ranges': 'bytes',
616
+ 'Access-Control-Allow-Origin': '*'
617
+ }
618
+ });
619
+ }
620
+ }