@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,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
|
+
}
|