@xiboplayer/cache 0.5.19 → 0.6.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 +42 -3
- package/package.json +2 -2
- package/src/cache-proxy.test.js +1 -206
- package/src/download-manager.js +37 -78
- package/src/file-types.js +26 -0
- package/src/index.d.ts +15 -16
- package/src/index.js +2 -2
- package/src/widget-html.js +24 -146
- package/src/widget-html.test.js +40 -119
- package/src/download-client.js +0 -222
|
@@ -130,13 +130,17 @@ The proxy server (`packages/proxy/src/proxy.js`) exposes these endpoints backed
|
|
|
130
130
|
|
|
131
131
|
| Method | Route | Purpose |
|
|
132
132
|
|--------|-------|---------|
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
135
|
-
| `PUT` | `/store/:type
|
|
133
|
+
| `HEAD` | `/store/:type/*` | Existence + size check (returns 404 for incomplete chunked files) |
|
|
134
|
+
| `GET` | `/store/:type/*` | Serve file (Range support) |
|
|
135
|
+
| `PUT` | `/store/:type/*` | Store file |
|
|
136
136
|
| `POST` | `/store/delete` | Delete files |
|
|
137
137
|
| `POST` | `/store/mark-complete` | Mark chunked download complete |
|
|
138
|
+
| `POST` | `/store/unmark-complete` | Unmark chunked file (keeps chunks, allows partial re-download) |
|
|
139
|
+
| `GET` | `/store/missing-chunks/:type/*` | Return missing chunk indices for a chunked file |
|
|
138
140
|
| `GET` | `/store/list` | List all cached files |
|
|
139
141
|
|
|
142
|
+
**Note:** HEAD must be registered before GET in Express 5 because `app.get()` also matches HEAD requests.
|
|
143
|
+
|
|
140
144
|
### ContentStore (Filesystem)
|
|
141
145
|
|
|
142
146
|
**Location**: `packages/proxy/src/content-store.js`
|
|
@@ -208,6 +212,41 @@ Widget HTML is processed on the main thread by `cacheWidgetHtml()`:
|
|
|
208
212
|
|
|
209
213
|
Static resources are stored before widget HTML to prevent race conditions when the iframe loads.
|
|
210
214
|
|
|
215
|
+
## Chunked Download Resume
|
|
216
|
+
|
|
217
|
+
Large media files are downloaded in chunks (configurable size, default ~100MB per chunk). If a download is interrupted (crash, network failure, process restart), the player resumes by only downloading missing chunks:
|
|
218
|
+
|
|
219
|
+
### How it works
|
|
220
|
+
|
|
221
|
+
1. **On startup/collection**: `enqueueFile()` calls `GET /store/missing-chunks/{storeKey}` before enqueueing
|
|
222
|
+
2. If chunks exist on disk, it populates `file.skipChunks` with the indices of cached chunks
|
|
223
|
+
3. The download pipeline skips those chunks, downloading only the missing ones
|
|
224
|
+
4. After all chunks arrive, the file is marked complete
|
|
225
|
+
|
|
226
|
+
### On video playback error
|
|
227
|
+
|
|
228
|
+
1. The renderer emits a `videoError` event when a `<video>` element fails
|
|
229
|
+
2. The PWA calls `GET /store/missing-chunks/{storeKey}` to check for missing chunks
|
|
230
|
+
3. If chunks are missing, it calls `POST /store/unmark-complete` (keeps all existing chunks on disk)
|
|
231
|
+
4. Triggers `collectNow()` — the normal enqueue path populates `skipChunks` and re-downloads only the missing chunks
|
|
232
|
+
|
|
233
|
+
### Incomplete file detection
|
|
234
|
+
|
|
235
|
+
- `HEAD /store/:type/*` returns **404** for chunked files with missing chunks (so the download pipeline picks them up)
|
|
236
|
+
- `cacheThrough()` falls through to the CMS for incomplete chunked files instead of serving broken data from the store
|
|
237
|
+
|
|
238
|
+
### Example
|
|
239
|
+
|
|
240
|
+
A 2GB video (21 chunks) where chunks 0 and 9 are cached:
|
|
241
|
+
```
|
|
242
|
+
GET /store/missing-chunks/api/v2/player/media/15
|
|
243
|
+
→ { "missing": [1,2,3,4,5,6,7,8,10,11,12,13,14,15,16,17,18,19,20], "numChunks": 21 }
|
|
244
|
+
|
|
245
|
+
# enqueueFile sets skipChunks = {0, 9}
|
|
246
|
+
# Download pipeline fetches only 19 chunks instead of 21
|
|
247
|
+
# Saves ~200MB of bandwidth
|
|
248
|
+
```
|
|
249
|
+
|
|
211
250
|
## Error Handling
|
|
212
251
|
|
|
213
252
|
| Scenario | StoreClient | DownloadClient |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/cache",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Offline caching and download management with parallel chunk downloads",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"spark-md5": "^3.0.2",
|
|
19
|
-
"@xiboplayer/utils": "0.
|
|
19
|
+
"@xiboplayer/utils": "0.6.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"vitest": "^2.0.0",
|
package/src/cache-proxy.test.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* StoreClient
|
|
2
|
+
* StoreClient Tests
|
|
3
3
|
*
|
|
4
4
|
* StoreClient: pure REST client for ContentStore — no SW dependency
|
|
5
|
-
* DownloadClient: SW postMessage client for download orchestration
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
9
8
|
import { StoreClient } from './store-client.js';
|
|
10
|
-
import { DownloadClient } from './download-client.js';
|
|
11
9
|
import { createTestBlob } from './test-utils.js';
|
|
12
10
|
|
|
13
11
|
/**
|
|
@@ -15,7 +13,6 @@ import { createTestBlob } from './test-utils.js';
|
|
|
15
13
|
*/
|
|
16
14
|
function resetMocks() {
|
|
17
15
|
global.fetch = vi.fn();
|
|
18
|
-
delete global.MessageChannel;
|
|
19
16
|
}
|
|
20
17
|
|
|
21
18
|
// ===========================================================================
|
|
@@ -189,205 +186,3 @@ describe('StoreClient', () => {
|
|
|
189
186
|
});
|
|
190
187
|
});
|
|
191
188
|
|
|
192
|
-
// ===========================================================================
|
|
193
|
-
// DownloadClient Tests
|
|
194
|
-
// ===========================================================================
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Helper: set up navigator.serviceWorker mock.
|
|
198
|
-
*/
|
|
199
|
-
function setupServiceWorker(opts = {}) {
|
|
200
|
-
const {
|
|
201
|
-
supported = true,
|
|
202
|
-
controller = null,
|
|
203
|
-
active = undefined,
|
|
204
|
-
installing = null,
|
|
205
|
-
waiting = null,
|
|
206
|
-
swReadyResolves = true,
|
|
207
|
-
} = opts;
|
|
208
|
-
|
|
209
|
-
if (!supported) {
|
|
210
|
-
Object.defineProperty(navigator, 'serviceWorker', {
|
|
211
|
-
value: undefined,
|
|
212
|
-
configurable: true,
|
|
213
|
-
writable: true,
|
|
214
|
-
});
|
|
215
|
-
delete navigator.serviceWorker;
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const activeSW = active !== undefined
|
|
220
|
-
? active
|
|
221
|
-
: controller
|
|
222
|
-
? { state: 'activated', postMessage: controller.postMessage }
|
|
223
|
-
: null;
|
|
224
|
-
|
|
225
|
-
const registration = {
|
|
226
|
-
active: activeSW,
|
|
227
|
-
installing,
|
|
228
|
-
waiting,
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
const messageListeners = [];
|
|
232
|
-
|
|
233
|
-
const swContainer = {
|
|
234
|
-
controller,
|
|
235
|
-
ready: swReadyResolves
|
|
236
|
-
? Promise.resolve(registration)
|
|
237
|
-
: new Promise(() => {}),
|
|
238
|
-
getRegistration: vi.fn().mockResolvedValue(registration),
|
|
239
|
-
addEventListener: vi.fn((event, handler) => {
|
|
240
|
-
if (event === 'message') {
|
|
241
|
-
messageListeners.push(handler);
|
|
242
|
-
}
|
|
243
|
-
}),
|
|
244
|
-
removeEventListener: vi.fn(),
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
swContainer._messageListeners = messageListeners;
|
|
248
|
-
swContainer._registration = registration;
|
|
249
|
-
|
|
250
|
-
Object.defineProperty(navigator, 'serviceWorker', {
|
|
251
|
-
value: swContainer,
|
|
252
|
-
configurable: true,
|
|
253
|
-
writable: true,
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
return swContainer;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function dispatchSWMessage(swContainer, data) {
|
|
260
|
-
for (const listener of swContainer._messageListeners || []) {
|
|
261
|
-
listener({ data });
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function setupMessageChannel() {
|
|
266
|
-
const channels = [];
|
|
267
|
-
|
|
268
|
-
global.MessageChannel = class {
|
|
269
|
-
constructor() {
|
|
270
|
-
const self = { port1: { onmessage: null }, port2: {} };
|
|
271
|
-
channels.push(self);
|
|
272
|
-
this.port1 = self.port1;
|
|
273
|
-
this.port2 = self.port2;
|
|
274
|
-
}
|
|
275
|
-
};
|
|
276
|
-
|
|
277
|
-
return {
|
|
278
|
-
get lastChannel() {
|
|
279
|
-
return channels[channels.length - 1];
|
|
280
|
-
},
|
|
281
|
-
respondOnLastChannel(data) {
|
|
282
|
-
const ch = channels[channels.length - 1];
|
|
283
|
-
if (ch && ch.port1.onmessage) {
|
|
284
|
-
ch.port1.onmessage({ data });
|
|
285
|
-
}
|
|
286
|
-
},
|
|
287
|
-
channels,
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
async function createInitialisedDownloadClient() {
|
|
292
|
-
const controller = { postMessage: vi.fn() };
|
|
293
|
-
const sw = setupServiceWorker({ controller });
|
|
294
|
-
|
|
295
|
-
const client = new DownloadClient();
|
|
296
|
-
const initPromise = client.init();
|
|
297
|
-
|
|
298
|
-
await Promise.resolve();
|
|
299
|
-
dispatchSWMessage(sw, { type: 'SW_READY' });
|
|
300
|
-
|
|
301
|
-
await initPromise;
|
|
302
|
-
return { client, sw, controller };
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
describe('DownloadClient', () => {
|
|
306
|
-
beforeEach(() => {
|
|
307
|
-
resetMocks();
|
|
308
|
-
Object.defineProperty(navigator, 'serviceWorker', {
|
|
309
|
-
value: undefined,
|
|
310
|
-
configurable: true,
|
|
311
|
-
writable: true,
|
|
312
|
-
});
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
describe('init()', () => {
|
|
316
|
-
it('should initialize with SW controller', async () => {
|
|
317
|
-
setupMessageChannel();
|
|
318
|
-
const { client } = await createInitialisedDownloadClient();
|
|
319
|
-
|
|
320
|
-
expect(client.controller).toBeTruthy();
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
it('should throw if SW not supported', async () => {
|
|
324
|
-
setupServiceWorker({ supported: false });
|
|
325
|
-
|
|
326
|
-
const client = new DownloadClient();
|
|
327
|
-
|
|
328
|
-
await expect(client.init()).rejects.toThrow('Service Worker not supported');
|
|
329
|
-
});
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
describe('download()', () => {
|
|
333
|
-
it('should post DOWNLOAD_FILES message to SW', async () => {
|
|
334
|
-
setupMessageChannel();
|
|
335
|
-
const { client } = await createInitialisedDownloadClient();
|
|
336
|
-
|
|
337
|
-
client.controller.postMessage = vi.fn();
|
|
338
|
-
|
|
339
|
-
const files = [
|
|
340
|
-
{ id: '1', type: 'media', path: 'http://test.com/file1.mp4' },
|
|
341
|
-
{ id: '2', type: 'media', path: 'http://test.com/file2.mp4' },
|
|
342
|
-
];
|
|
343
|
-
|
|
344
|
-
const mc = setupMessageChannel();
|
|
345
|
-
const downloadPromise = client.download(files);
|
|
346
|
-
|
|
347
|
-
// Simulate SW acknowledging the download
|
|
348
|
-
mc.respondOnLastChannel({
|
|
349
|
-
success: true,
|
|
350
|
-
enqueuedCount: 2,
|
|
351
|
-
activeCount: 2,
|
|
352
|
-
queuedCount: 0,
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
await expect(downloadPromise).resolves.toBeUndefined();
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
it('should reject when SW returns error', async () => {
|
|
359
|
-
setupMessageChannel();
|
|
360
|
-
const { client } = await createInitialisedDownloadClient();
|
|
361
|
-
|
|
362
|
-
client.controller.postMessage = vi.fn();
|
|
363
|
-
|
|
364
|
-
const mc = setupMessageChannel();
|
|
365
|
-
const downloadPromise = client.download([]);
|
|
366
|
-
|
|
367
|
-
mc.respondOnLastChannel({ success: false, error: 'Download failed' });
|
|
368
|
-
|
|
369
|
-
await expect(downloadPromise).rejects.toThrow('Download failed');
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
it('should throw if SW controller not available', async () => {
|
|
373
|
-
setupMessageChannel();
|
|
374
|
-
const { client } = await createInitialisedDownloadClient();
|
|
375
|
-
|
|
376
|
-
client.controller = null;
|
|
377
|
-
|
|
378
|
-
await expect(client.download([])).rejects.toThrow('Service Worker not available');
|
|
379
|
-
});
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
describe('getProgress()', () => {
|
|
383
|
-
it('should return empty object when controller is null', async () => {
|
|
384
|
-
setupMessageChannel();
|
|
385
|
-
const { client } = await createInitialisedDownloadClient();
|
|
386
|
-
client.controller = null;
|
|
387
|
-
|
|
388
|
-
const progress = await client.getProgress();
|
|
389
|
-
|
|
390
|
-
expect(progress).toEqual({});
|
|
391
|
-
});
|
|
392
|
-
});
|
|
393
|
-
});
|
package/src/download-manager.js
CHANGED
|
@@ -26,37 +26,17 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { createLogger } from '@xiboplayer/utils';
|
|
29
|
+
import { getFileTypeConfig } from './file-types.js';
|
|
29
30
|
|
|
30
31
|
const log = createLogger('Download');
|
|
31
32
|
const DEFAULT_CONCURRENCY = 6; // Max concurrent HTTP connections (matches Chromium per-host limit)
|
|
32
33
|
const DEFAULT_CHUNK_SIZE = 50 * 1024 * 1024; // 50MB chunks
|
|
33
34
|
const DEFAULT_MAX_CHUNKS_PER_FILE = 3; // Max parallel chunk downloads per file
|
|
34
35
|
const CHUNK_THRESHOLD = 100 * 1024 * 1024; // Files > 100MB get chunked
|
|
35
|
-
const MAX_RETRIES = 3;
|
|
36
|
-
const RETRY_DELAY_MS = 500; // Fast: 500ms, 1s, 1.5s → total ~3s
|
|
37
|
-
|
|
38
|
-
// getData (widget data) retry config — CMS "cache not ready" (HTTP 500) resolves
|
|
39
|
-
// when the XTR task runs (30-120s). Use longer backoff to ride it out.
|
|
40
|
-
const GETDATA_MAX_RETRIES = 4;
|
|
41
|
-
const GETDATA_RETRY_DELAYS = [15_000, 30_000, 60_000, 120_000]; // 15s, 30s, 60s, 120s
|
|
42
|
-
const GETDATA_REENQUEUE_DELAY_MS = 60_000; // Re-add to queue after 60s if all retries fail
|
|
43
|
-
const GETDATA_MAX_REENQUEUES = 5; // Max times a getData can be re-enqueued before permanent failure
|
|
44
36
|
const URGENT_CONCURRENCY = 2; // Slots when urgent chunk is active (bandwidth focus)
|
|
45
37
|
const FETCH_TIMEOUT_MS = 600_000; // 10 minutes — 100MB chunk at ~2 Mbps
|
|
46
38
|
const HEAD_TIMEOUT_MS = 15_000; // 15 seconds for HEAD requests
|
|
47
39
|
|
|
48
|
-
// CMS origin for proxy filtering — set via setCmsOrigin() at init
|
|
49
|
-
let _cmsOrigin = null;
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Set the CMS origin so toProxyUrl() only proxies CMS URLs.
|
|
53
|
-
* External URLs (CDNs, Google Fonts, geolocation APIs) pass through unchanged.
|
|
54
|
-
* @param {string} origin - e.g. 'https://cms.example.com'
|
|
55
|
-
*/
|
|
56
|
-
export function setCmsOrigin(origin) {
|
|
57
|
-
_cmsOrigin = origin;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
40
|
/**
|
|
61
41
|
* Infer Content-Type from file path extension.
|
|
62
42
|
* Used when we skip HEAD (size already known from RequiredFiles).
|
|
@@ -116,20 +96,6 @@ export function isUrlExpired(url, graceSeconds = 30) {
|
|
|
116
96
|
return (Date.now() / 1000) >= (expiry - graceSeconds);
|
|
117
97
|
}
|
|
118
98
|
|
|
119
|
-
/**
|
|
120
|
-
* Rewrite an absolute CMS URL through the local proxy when running behind
|
|
121
|
-
* the proxy server (Chromium kiosk or Electron).
|
|
122
|
-
* Detection: SW/window on localhost (any port) = proxy mode.
|
|
123
|
-
*/
|
|
124
|
-
export function toProxyUrl(url) {
|
|
125
|
-
if (!url.startsWith('http')) return url;
|
|
126
|
-
const loc = typeof self !== 'undefined' ? self.location : undefined;
|
|
127
|
-
if (!loc || loc.hostname !== 'localhost') return url;
|
|
128
|
-
const parsed = new URL(url);
|
|
129
|
-
// Only proxy URLs belonging to the CMS server; external URLs pass through
|
|
130
|
-
if (_cmsOrigin && parsed.origin !== _cmsOrigin) return url;
|
|
131
|
-
return `/file-proxy?cms=${encodeURIComponent(parsed.origin)}&url=${encodeURIComponent(parsed.pathname + parsed.search)}`;
|
|
132
|
-
}
|
|
133
99
|
|
|
134
100
|
/**
|
|
135
101
|
* DownloadTask - Single HTTP fetch unit
|
|
@@ -147,8 +113,7 @@ export class DownloadTask {
|
|
|
147
113
|
this.blob = null;
|
|
148
114
|
this._parentFile = null;
|
|
149
115
|
this._priority = PRIORITY.normal;
|
|
150
|
-
|
|
151
|
-
this.isGetData = fileInfo.isGetData || false;
|
|
116
|
+
this._typeConfig = getFileTypeConfig(fileInfo.type);
|
|
152
117
|
}
|
|
153
118
|
|
|
154
119
|
getUrl() {
|
|
@@ -156,24 +121,7 @@ export class DownloadTask {
|
|
|
156
121
|
if (isUrlExpired(url)) {
|
|
157
122
|
throw new Error(`URL expired for ${this.fileInfo.type}/${this.fileInfo.id} — waiting for fresh URL from next collection cycle`);
|
|
158
123
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
// Append store key params so the proxy can save to ContentStore
|
|
162
|
-
if (proxyUrl.startsWith('/file-proxy')) {
|
|
163
|
-
const storeKey = `${this.fileInfo.type || 'media'}/${this.fileInfo.id}`;
|
|
164
|
-
proxyUrl += `&storeKey=${encodeURIComponent(storeKey)}`;
|
|
165
|
-
if (this.chunkIndex != null) {
|
|
166
|
-
proxyUrl += `&chunkIndex=${this.chunkIndex}`;
|
|
167
|
-
if (this._parentFile) {
|
|
168
|
-
proxyUrl += `&numChunks=${this._parentFile.totalChunks}`;
|
|
169
|
-
proxyUrl += `&chunkSize=${this._parentFile.options.chunkSize || 104857600}`;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
if (this.fileInfo.md5) {
|
|
173
|
-
proxyUrl += `&md5=${encodeURIComponent(this.fileInfo.md5)}`;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
return proxyUrl;
|
|
124
|
+
return url;
|
|
177
125
|
}
|
|
178
126
|
|
|
179
127
|
async start() {
|
|
@@ -182,8 +130,22 @@ export class DownloadTask {
|
|
|
182
130
|
if (this.rangeStart != null) {
|
|
183
131
|
headers['Range'] = `bytes=${this.rangeStart}-${this.rangeEnd}`;
|
|
184
132
|
}
|
|
133
|
+
// Pass chunk metadata and MD5 via custom headers for cache-through proxy
|
|
134
|
+
if (this.chunkIndex != null) {
|
|
135
|
+
headers['X-Store-Chunk-Index'] = String(this.chunkIndex);
|
|
136
|
+
if (this._parentFile) {
|
|
137
|
+
headers['X-Store-Num-Chunks'] = String(this._parentFile.totalChunks);
|
|
138
|
+
headers['X-Store-Chunk-Size'] = String(this._parentFile.options.chunkSize || 104857600);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (this.fileInfo.md5) {
|
|
142
|
+
headers['X-Store-MD5'] = this.fileInfo.md5;
|
|
143
|
+
}
|
|
144
|
+
if (this.fileInfo.updateInterval) {
|
|
145
|
+
headers['X-Cache-TTL'] = String(this.fileInfo.updateInterval);
|
|
146
|
+
}
|
|
185
147
|
|
|
186
|
-
const maxRetries = this.
|
|
148
|
+
const maxRetries = this._typeConfig.maxRetries;
|
|
187
149
|
|
|
188
150
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
189
151
|
const ac = new AbortController();
|
|
@@ -205,9 +167,8 @@ export class DownloadTask {
|
|
|
205
167
|
} catch (error) {
|
|
206
168
|
const msg = ac.signal.aborted ? `Timeout after ${FETCH_TIMEOUT_MS / 1000}s` : error.message;
|
|
207
169
|
if (attempt < maxRetries) {
|
|
208
|
-
const delay = this.
|
|
209
|
-
|
|
210
|
-
: RETRY_DELAY_MS * attempt;
|
|
170
|
+
const delay = this._typeConfig.retryDelays?.[attempt - 1]
|
|
171
|
+
?? this._typeConfig.retryDelayMs * attempt;
|
|
211
172
|
const chunkLabel = this.chunkIndex != null ? ` chunk ${this.chunkIndex}` : '';
|
|
212
173
|
log.warn(`[DownloadTask] ${this.fileInfo.type}/${this.fileInfo.id}${chunkLabel} attempt ${attempt}/${maxRetries} failed: ${msg}. Retrying in ${delay / 1000}s...`);
|
|
213
174
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
@@ -264,17 +225,7 @@ export class FileDownload {
|
|
|
264
225
|
if (isUrlExpired(url)) {
|
|
265
226
|
throw new Error(`URL expired for ${this.fileInfo.type}/${this.fileInfo.id} — waiting for fresh URL from next collection cycle`);
|
|
266
227
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
// Append store key for ContentStore (same as DownloadTask)
|
|
270
|
-
if (proxyUrl.startsWith('/file-proxy')) {
|
|
271
|
-
const storeKey = `${this.fileInfo.type || 'media'}/${this.fileInfo.id}`;
|
|
272
|
-
proxyUrl += `&storeKey=${encodeURIComponent(storeKey)}`;
|
|
273
|
-
if (this.fileInfo.md5) {
|
|
274
|
-
proxyUrl += `&md5=${encodeURIComponent(this.fileInfo.md5)}`;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
return proxyUrl;
|
|
228
|
+
return url;
|
|
278
229
|
}
|
|
279
230
|
|
|
280
231
|
wait() {
|
|
@@ -296,7 +247,12 @@ export class FileDownload {
|
|
|
296
247
|
this.totalBytes = (size && size > 0) ? parseInt(size) : 0;
|
|
297
248
|
this._contentType = inferContentType(this.fileInfo);
|
|
298
249
|
|
|
299
|
-
|
|
250
|
+
// Skip HEAD for types that declare skipHead (e.g. datasets — dynamic API endpoints).
|
|
251
|
+
// These generate responses server-side; HEAD triggers the full handler for nothing
|
|
252
|
+
// and may fail if the CMS cache isn't warm yet. They're always small, never chunked.
|
|
253
|
+
const skipHead = getFileTypeConfig(this.fileInfo.type).skipHead;
|
|
254
|
+
|
|
255
|
+
if (this.totalBytes === 0 && !skipHead) {
|
|
300
256
|
// No size declared — HEAD fallback (rare: only for files without CMS size)
|
|
301
257
|
const url = this.getUrl();
|
|
302
258
|
const ac = new AbortController();
|
|
@@ -916,25 +872,26 @@ export class DownloadQueue {
|
|
|
916
872
|
task._parentFile._runningCount--;
|
|
917
873
|
this._activeTasks = this._activeTasks.filter(t => t !== task);
|
|
918
874
|
|
|
919
|
-
//
|
|
875
|
+
// Re-enqueueable types (e.g. datasets): defer re-enqueue instead of permanent failure.
|
|
920
876
|
// CMS "cache not ready" resolves when the XTR task runs (30-120s).
|
|
921
|
-
|
|
877
|
+
const { maxReenqueues, reenqueueDelayMs } = task._typeConfig;
|
|
878
|
+
if (maxReenqueues > 0) {
|
|
922
879
|
task._reenqueueCount = (task._reenqueueCount || 0) + 1;
|
|
923
|
-
if (task._reenqueueCount >
|
|
924
|
-
log.error(`[DownloadQueue]
|
|
880
|
+
if (task._reenqueueCount > maxReenqueues) {
|
|
881
|
+
log.error(`[DownloadQueue] ${key} exceeded ${maxReenqueues} re-enqueues, failing permanently`);
|
|
925
882
|
this.processQueue();
|
|
926
883
|
task._parentFile.onTaskFailed(task, err);
|
|
927
884
|
return;
|
|
928
885
|
}
|
|
929
|
-
log.warn(`[DownloadQueue]
|
|
886
|
+
log.warn(`[DownloadQueue] ${key} failed all retries (attempt ${task._reenqueueCount}/${maxReenqueues}), scheduling re-enqueue in ${reenqueueDelayMs / 1000}s`);
|
|
930
887
|
const timerId = setTimeout(() => {
|
|
931
888
|
this._reenqueueTimers.delete(timerId);
|
|
932
889
|
task.state = 'pending';
|
|
933
890
|
task._parentFile.state = 'downloading';
|
|
934
891
|
this.queue.push(task);
|
|
935
|
-
log.info(`[DownloadQueue]
|
|
892
|
+
log.info(`[DownloadQueue] ${key} re-enqueued for retry`);
|
|
936
893
|
this.processQueue();
|
|
937
|
-
},
|
|
894
|
+
}, reenqueueDelayMs);
|
|
938
895
|
this._reenqueueTimers.add(timerId);
|
|
939
896
|
this.processQueue();
|
|
940
897
|
return;
|
|
@@ -978,6 +935,8 @@ export class DownloadQueue {
|
|
|
978
935
|
getProgress() {
|
|
979
936
|
const progress = {};
|
|
980
937
|
for (const [key, file] of this.active.entries()) {
|
|
938
|
+
// Skip completed/failed — they stay in active until removeCompleted() runs
|
|
939
|
+
if (file.state === 'complete' || file.state === 'failed') continue;
|
|
981
940
|
progress[key] = {
|
|
982
941
|
downloaded: file.downloadedBytes,
|
|
983
942
|
total: file.totalBytes,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FILE_TYPES — centralized download behavior per file type.
|
|
3
|
+
*
|
|
4
|
+
* Each type declares retry strategy, HEAD skip, and cache TTL.
|
|
5
|
+
* Used by DownloadTask/FileDownload instead of ad-hoc isGetData checks.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const FILE_TYPES = {
|
|
9
|
+
media: { maxRetries: 3, retryDelayMs: 500, retryDelays: null,
|
|
10
|
+
maxReenqueues: 0, reenqueueDelayMs: 0,
|
|
11
|
+
skipHead: false, cacheTtl: Infinity },
|
|
12
|
+
layout: { maxRetries: 3, retryDelayMs: 500, retryDelays: null,
|
|
13
|
+
maxReenqueues: 0, reenqueueDelayMs: 0,
|
|
14
|
+
skipHead: false, cacheTtl: Infinity },
|
|
15
|
+
dataset: { maxRetries: 4, retryDelayMs: 0,
|
|
16
|
+
retryDelays: [15_000, 30_000, 60_000, 120_000],
|
|
17
|
+
maxReenqueues: 5, reenqueueDelayMs: 60_000,
|
|
18
|
+
skipHead: true, cacheTtl: 300 },
|
|
19
|
+
static: { maxRetries: 3, retryDelayMs: 500, retryDelays: null,
|
|
20
|
+
maxReenqueues: 0, reenqueueDelayMs: 0,
|
|
21
|
+
skipHead: false, cacheTtl: Infinity },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function getFileTypeConfig(type) {
|
|
25
|
+
return FILE_TYPES[type] || FILE_TYPES.media;
|
|
26
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -8,25 +8,26 @@ export class StoreClient {
|
|
|
8
8
|
list(): Promise<Array<{ id: string; type: string; size: number }>>;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export class DownloadClient {
|
|
12
|
-
controller: ServiceWorker | null;
|
|
13
|
-
fetchReady: boolean;
|
|
14
|
-
init(): Promise<void>;
|
|
15
|
-
download(payload: object | any[]): Promise<void>;
|
|
16
|
-
prioritize(fileType: string, fileId: string): Promise<void>;
|
|
17
|
-
prioritizeLayout(mediaIds: string[]): Promise<void>;
|
|
18
|
-
getProgress(): Promise<Record<string, any>>;
|
|
19
|
-
cleanup(): void;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
11
|
export class DownloadManager {
|
|
23
|
-
constructor(options?: { concurrency?: number; chunkSize?: number;
|
|
12
|
+
constructor(options?: { concurrency?: number; chunkSize?: number; chunksPerFile?: number });
|
|
24
13
|
enqueue(fileInfo: any): any;
|
|
14
|
+
getTask(key: string): any;
|
|
15
|
+
getProgress(): Record<string, any>;
|
|
25
16
|
prioritizeLayoutFiles(mediaIds: string[]): void;
|
|
17
|
+
clear(): void;
|
|
18
|
+
queue: any;
|
|
26
19
|
}
|
|
27
20
|
|
|
28
|
-
export class FileDownload {
|
|
29
|
-
|
|
21
|
+
export class FileDownload {
|
|
22
|
+
state: string;
|
|
23
|
+
wait(): Promise<Blob>;
|
|
24
|
+
}
|
|
25
|
+
export class LayoutTaskBuilder {
|
|
26
|
+
constructor(queue: any);
|
|
27
|
+
addFile(fileInfo: any): FileDownload;
|
|
28
|
+
build(): Promise<any[]>;
|
|
29
|
+
}
|
|
30
|
+
export const BARRIER: symbol;
|
|
30
31
|
export class CacheManager {}
|
|
31
32
|
export class CacheAnalyzer {
|
|
32
33
|
constructor(store: StoreClient);
|
|
@@ -35,6 +36,4 @@ export class CacheAnalyzer {
|
|
|
35
36
|
export const cacheManager: CacheManager;
|
|
36
37
|
|
|
37
38
|
export function isUrlExpired(url: string): boolean;
|
|
38
|
-
export function toProxyUrl(url: string): string;
|
|
39
|
-
export function setCmsOrigin(origin: string): void;
|
|
40
39
|
export function cacheWidgetHtml(...args: any[]): any;
|
package/src/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import pkg from '../package.json' with { type: 'json' };
|
|
|
3
3
|
export const VERSION = pkg.version;
|
|
4
4
|
export { CacheManager, cacheManager } from './cache.js';
|
|
5
5
|
export { StoreClient } from './store-client.js';
|
|
6
|
-
export {
|
|
7
|
-
export { DownloadManager, FileDownload, LayoutTaskBuilder, isUrlExpired, toProxyUrl, setCmsOrigin } from './download-manager.js';
|
|
6
|
+
export { DownloadManager, FileDownload, LayoutTaskBuilder, BARRIER, isUrlExpired } from './download-manager.js';
|
|
8
7
|
export { CacheAnalyzer } from './cache-analyzer.js';
|
|
9
8
|
export { cacheWidgetHtml } from './widget-html.js';
|
|
9
|
+
export { FILE_TYPES, getFileTypeConfig } from './file-types.js';
|