@xiboplayer/sw 0.2.0 → 0.3.1
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
package/README.md
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
# @xiboplayer/sw
|
|
1
|
+
# @xiboplayer/sw
|
|
2
2
|
|
|
3
3
|
**Service Worker toolkit for chunk streaming and offline caching.**
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
Provides Service Worker
|
|
7
|
+
Provides Service Worker building blocks for Xibo players:
|
|
8
8
|
|
|
9
|
-
- **Chunk streaming**
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
9
|
+
- **Chunk streaming** — progressive download with Range request support for large media files
|
|
10
|
+
- **BlobCache** — in-memory cache for assembled chunks with LRU eviction
|
|
11
|
+
- **Widget HTML serving** — intercepts GetResource requests and serves from cache
|
|
12
|
+
- **Version-aware activation** — prevents re-activation of same SW version to preserve in-flight streams
|
|
13
|
+
- **XLF-driven media resolution** — parses layout XLF to determine exactly which media each layout needs
|
|
13
14
|
|
|
14
15
|
## Installation
|
|
15
16
|
|
|
@@ -17,47 +18,15 @@ Provides Service Worker utilities for:
|
|
|
17
18
|
npm install @xiboplayer/sw
|
|
18
19
|
```
|
|
19
20
|
|
|
20
|
-
## Usage
|
|
21
|
-
|
|
22
|
-
```javascript
|
|
23
|
-
// In main thread
|
|
24
|
-
import { registerServiceWorker } from '@xiboplayer/sw';
|
|
25
|
-
|
|
26
|
-
await registerServiceWorker('/sw.js');
|
|
27
|
-
|
|
28
|
-
// In service worker
|
|
29
|
-
import { setupChunkStreaming } from '@xiboplayer/sw/worker';
|
|
30
|
-
|
|
31
|
-
self.addEventListener('install', event => {
|
|
32
|
-
event.waitUntil(setupChunkStreaming());
|
|
33
|
-
});
|
|
34
|
-
```
|
|
35
|
-
|
|
36
21
|
## Features
|
|
37
22
|
|
|
38
|
-
|
|
39
|
-
- Automatic range request handling
|
|
40
|
-
- Cache API integration
|
|
41
|
-
- Update notifications
|
|
42
|
-
|
|
43
|
-
## API Reference
|
|
44
|
-
|
|
45
|
-
### registerServiceWorker(url)
|
|
46
|
-
|
|
47
|
-
Registers Service Worker with update handling.
|
|
48
|
-
|
|
49
|
-
### setupChunkStreaming()
|
|
50
|
-
|
|
51
|
-
Configures SW for chunk-based streaming.
|
|
52
|
-
|
|
53
|
-
## Dependencies
|
|
54
|
-
|
|
55
|
-
None (zero dependencies)
|
|
56
|
-
|
|
57
|
-
## Related Packages
|
|
23
|
+
This package provides the core logic used by the PWA Service Worker (`sw-pwa.js`). The SW handles:
|
|
58
24
|
|
|
59
|
-
|
|
25
|
+
1. **Static caching** — PWA shell files cached on install
|
|
26
|
+
2. **Media caching** — layout media downloaded via parallel chunks
|
|
27
|
+
3. **Range requests** — video seeking served from cached chunks
|
|
28
|
+
4. **Download queue** — flat queue with barriers for layout-ordered downloads
|
|
60
29
|
|
|
61
30
|
---
|
|
62
31
|
|
|
63
|
-
**
|
|
32
|
+
**Part of the [XiboPlayer SDK](https://github.com/xibo-players/xiboplayer)** | [MCP Server](https://github.com/xibo-players/xiboplayer/tree/main/mcp-server) for AI-assisted development
|
package/package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/sw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Service Worker toolkit for chunk streaming and offline caching",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.js",
|
|
9
|
-
"./
|
|
9
|
+
"./utils": "./src/sw-utils.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@xiboplayer/cache": "0.3.1"
|
|
10
13
|
},
|
|
11
|
-
"dependencies": {},
|
|
12
14
|
"devDependencies": {
|
|
13
15
|
"vitest": "^2.0.0"
|
|
14
16
|
},
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlobCache - LRU in-memory cache for blob objects
|
|
3
|
+
* Prevents re-materializing blobs from Cache API on every Range request
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { formatBytes } from './sw-utils.js';
|
|
7
|
+
import { SWLogger } from './chunk-config.js';
|
|
8
|
+
|
|
9
|
+
export class BlobCache {
|
|
10
|
+
/**
|
|
11
|
+
* @param {number} [maxSizeMB=200] - Maximum cache size in megabytes
|
|
12
|
+
*/
|
|
13
|
+
constructor(maxSizeMB = 200) {
|
|
14
|
+
this.cache = new Map(); // cacheKey → { blob, lastAccess, size }
|
|
15
|
+
this.maxBytes = maxSizeMB * 1024 * 1024;
|
|
16
|
+
this.currentBytes = 0;
|
|
17
|
+
this.log = new SWLogger('BlobCache');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check if a key exists in memory cache (no Cache API fallback)
|
|
22
|
+
* @param {string} cacheKey - Cache key
|
|
23
|
+
* @returns {boolean}
|
|
24
|
+
*/
|
|
25
|
+
has(cacheKey) {
|
|
26
|
+
return this.cache.has(cacheKey);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get blob from cache or load via loader function
|
|
31
|
+
* @param {string} cacheKey - Cache key
|
|
32
|
+
* @param {Function} loader - Async function that returns blob
|
|
33
|
+
* @returns {Promise<Blob>}
|
|
34
|
+
*/
|
|
35
|
+
async get(cacheKey, loader) {
|
|
36
|
+
// Check memory cache first
|
|
37
|
+
if (this.cache.has(cacheKey)) {
|
|
38
|
+
const entry = this.cache.get(cacheKey);
|
|
39
|
+
entry.lastAccess = Date.now();
|
|
40
|
+
this.log.debug(`HIT: ${cacheKey} (${formatBytes(entry.size)})`);
|
|
41
|
+
return entry.blob;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Cache miss - load from Cache API
|
|
45
|
+
this.log.debug(`MISS: ${cacheKey} - loading from Cache API`);
|
|
46
|
+
const blob = await loader();
|
|
47
|
+
|
|
48
|
+
// Evict LRU entries if over limit
|
|
49
|
+
while (this.currentBytes + blob.size > this.maxBytes && this.cache.size > 0) {
|
|
50
|
+
this.evictLRU();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Cache if under limit
|
|
54
|
+
if (this.currentBytes + blob.size <= this.maxBytes) {
|
|
55
|
+
this.cache.set(cacheKey, {
|
|
56
|
+
blob,
|
|
57
|
+
lastAccess: Date.now(),
|
|
58
|
+
size: blob.size
|
|
59
|
+
});
|
|
60
|
+
this.currentBytes += blob.size;
|
|
61
|
+
const utilization = (this.currentBytes / this.maxBytes * 100).toFixed(1);
|
|
62
|
+
this.log.debug(`CACHED: ${cacheKey} (${formatBytes(blob.size)}) - utilization: ${utilization}%`);
|
|
63
|
+
} else {
|
|
64
|
+
this.log.debug(`Skipping memory cache (too large): ${cacheKey} (${formatBytes(blob.size)} > ${formatBytes(this.maxBytes)})`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return blob;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Evict least recently used entry
|
|
72
|
+
*/
|
|
73
|
+
evictLRU() {
|
|
74
|
+
let oldest = null;
|
|
75
|
+
let oldestKey = null;
|
|
76
|
+
|
|
77
|
+
for (const [key, entry] of this.cache) {
|
|
78
|
+
if (!oldest || entry.lastAccess < oldest.lastAccess) {
|
|
79
|
+
oldest = entry;
|
|
80
|
+
oldestKey = key;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (oldestKey) {
|
|
85
|
+
this.currentBytes -= oldest.size;
|
|
86
|
+
this.cache.delete(oldestKey);
|
|
87
|
+
this.log.debug(`EVICTED LRU: ${oldestKey} (${formatBytes(oldest.size)})`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Clear all cached blobs
|
|
93
|
+
*/
|
|
94
|
+
clear() {
|
|
95
|
+
this.cache.clear();
|
|
96
|
+
this.currentBytes = 0;
|
|
97
|
+
this.log.info('Cleared all cached blobs');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get cache statistics
|
|
102
|
+
*/
|
|
103
|
+
getStats() {
|
|
104
|
+
return {
|
|
105
|
+
entries: this.cache.size,
|
|
106
|
+
bytes: this.currentBytes,
|
|
107
|
+
maxBytes: this.maxBytes,
|
|
108
|
+
utilization: (this.currentBytes / this.maxBytes * 100).toFixed(1) + '%'
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CacheManager - Wraps Cache API with type-aware keys and chunk support
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { formatBytes } from './sw-utils.js';
|
|
6
|
+
import { SWLogger } from './chunk-config.js';
|
|
7
|
+
|
|
8
|
+
export class CacheManager {
|
|
9
|
+
/**
|
|
10
|
+
* @param {Object} [options]
|
|
11
|
+
* @param {string} [options.cacheName='xibo-media-v1'] - Cache Storage name
|
|
12
|
+
* @param {number} [options.chunkSize] - Chunk size in bytes (required for putChunked)
|
|
13
|
+
*/
|
|
14
|
+
constructor({ cacheName = 'xibo-media-v1', chunkSize } = {}) {
|
|
15
|
+
this.cache = null;
|
|
16
|
+
this.cacheName = cacheName;
|
|
17
|
+
this.chunkSize = chunkSize;
|
|
18
|
+
this.log = new SWLogger('Cache');
|
|
19
|
+
|
|
20
|
+
// In-memory metadata cache: cacheKey → metadata object
|
|
21
|
+
// Eliminates Cache API lookups for chunk metadata on every Range request
|
|
22
|
+
this.metadataCache = new Map();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async init() {
|
|
26
|
+
this.cache = await caches.open(this.cacheName);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get cached file
|
|
31
|
+
* Returns Response or null
|
|
32
|
+
*/
|
|
33
|
+
async get(cacheKey) {
|
|
34
|
+
if (!this.cache) await this.init();
|
|
35
|
+
// Use ignoreVary and ignoreSearch for more lenient matching
|
|
36
|
+
return await this.cache.match(cacheKey, {
|
|
37
|
+
ignoreSearch: true,
|
|
38
|
+
ignoreVary: true
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Put file in cache
|
|
44
|
+
*/
|
|
45
|
+
async put(cacheKey, blob, contentType) {
|
|
46
|
+
if (!this.cache) await this.init();
|
|
47
|
+
|
|
48
|
+
const response = new Response(blob, {
|
|
49
|
+
headers: {
|
|
50
|
+
'Content-Type': contentType,
|
|
51
|
+
'Content-Length': blob.size,
|
|
52
|
+
'Access-Control-Allow-Origin': '*',
|
|
53
|
+
'Accept-Ranges': 'bytes'
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await this.cache.put(cacheKey, response);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Delete file from cache (whole file, or all chunks + metadata)
|
|
62
|
+
*/
|
|
63
|
+
async delete(cacheKey) {
|
|
64
|
+
if (!this.cache) await this.init();
|
|
65
|
+
|
|
66
|
+
// Clear in-memory metadata cache
|
|
67
|
+
const meta = this.metadataCache.get(cacheKey);
|
|
68
|
+
this.metadataCache.delete(cacheKey);
|
|
69
|
+
|
|
70
|
+
// If chunked, delete all chunks + metadata
|
|
71
|
+
if (meta) {
|
|
72
|
+
const promises = [this.cache.delete(`${cacheKey}/metadata`)];
|
|
73
|
+
for (let i = 0; i < meta.numChunks; i++) {
|
|
74
|
+
promises.push(this.cache.delete(`${cacheKey}/chunk-${i}`));
|
|
75
|
+
}
|
|
76
|
+
await Promise.all(promises);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return await this.cache.delete(cacheKey);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Clear all cached files
|
|
85
|
+
*/
|
|
86
|
+
async clear() {
|
|
87
|
+
if (!this.cache) await this.init();
|
|
88
|
+
const keys = await this.cache.keys();
|
|
89
|
+
await Promise.all(keys.map(key => this.cache.delete(key)));
|
|
90
|
+
this.metadataCache.clear();
|
|
91
|
+
this.log.info('Cleared', keys.length, 'cached files');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Check if file exists (supports both whole files and chunked storage)
|
|
96
|
+
* Single source of truth for file existence checks.
|
|
97
|
+
* Uses in-memory metadataCache to avoid Cache API lookups on hot paths.
|
|
98
|
+
* @param {string} cacheKey - Full cache key (e.g., /player/pwa/cache/media/6)
|
|
99
|
+
* @returns {Promise<{exists: boolean, chunked: boolean, metadata: Object|null}>}
|
|
100
|
+
*/
|
|
101
|
+
async fileExists(cacheKey) {
|
|
102
|
+
if (!this.cache) await this.init();
|
|
103
|
+
|
|
104
|
+
// Fast path: check in-memory metadata cache first (no async I/O)
|
|
105
|
+
const cachedMeta = this.metadataCache.get(cacheKey);
|
|
106
|
+
if (cachedMeta) {
|
|
107
|
+
return { exists: true, chunked: true, metadata: cachedMeta };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Check for whole file
|
|
111
|
+
const wholeFile = await this.get(cacheKey);
|
|
112
|
+
if (wholeFile) {
|
|
113
|
+
return { exists: true, chunked: false, metadata: null };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Check for chunked metadata (Cache API fallback)
|
|
117
|
+
const metadata = await this.getMetadata(cacheKey);
|
|
118
|
+
if (metadata && metadata.chunked) {
|
|
119
|
+
// Populate in-memory cache for future requests
|
|
120
|
+
this.metadataCache.set(cacheKey, metadata);
|
|
121
|
+
return { exists: true, chunked: true, metadata };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { exists: false, chunked: false, metadata: null };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get file size (works for both whole files and chunks)
|
|
129
|
+
* @param {string} cacheKey - Full cache key
|
|
130
|
+
* @returns {Promise<number|null>} File size in bytes, or null if not found
|
|
131
|
+
*/
|
|
132
|
+
async getFileSize(cacheKey) {
|
|
133
|
+
const info = await this.fileExists(cacheKey);
|
|
134
|
+
|
|
135
|
+
if (!info.exists) return null;
|
|
136
|
+
|
|
137
|
+
if (info.chunked) {
|
|
138
|
+
return info.metadata.totalSize; // From chunked metadata
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const response = await this.get(cacheKey);
|
|
142
|
+
const contentLength = response?.headers.get('Content-Length');
|
|
143
|
+
return contentLength ? parseInt(contentLength) : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Store file as chunks for large files (low memory streaming)
|
|
148
|
+
* @param {string} cacheKey - Base cache key (e.g., /player/pwa/cache/media/123)
|
|
149
|
+
* @param {Blob} blob - File blob to store as chunks
|
|
150
|
+
* @param {string} contentType - Content type
|
|
151
|
+
*/
|
|
152
|
+
async putChunked(cacheKey, blob, contentType) {
|
|
153
|
+
if (!this.cache) await this.init();
|
|
154
|
+
|
|
155
|
+
const chunkSize = this.chunkSize;
|
|
156
|
+
const totalSize = blob.size;
|
|
157
|
+
const numChunks = Math.ceil(totalSize / chunkSize);
|
|
158
|
+
|
|
159
|
+
this.log.info(`Storing as ${numChunks} chunks: ${cacheKey} (${formatBytes(totalSize)})`);
|
|
160
|
+
|
|
161
|
+
// Store metadata (complete: false until all chunks are written)
|
|
162
|
+
const metadata = {
|
|
163
|
+
totalSize,
|
|
164
|
+
chunkSize,
|
|
165
|
+
numChunks,
|
|
166
|
+
contentType,
|
|
167
|
+
chunked: true,
|
|
168
|
+
complete: false,
|
|
169
|
+
createdAt: Date.now()
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const metadataResponse = new Response(JSON.stringify(metadata), {
|
|
173
|
+
headers: { 'Content-Type': 'application/json' }
|
|
174
|
+
});
|
|
175
|
+
await this.cache.put(`${cacheKey}/metadata`, metadataResponse);
|
|
176
|
+
// Populate in-memory cache
|
|
177
|
+
this.metadataCache.set(cacheKey, metadata);
|
|
178
|
+
|
|
179
|
+
// Store chunks
|
|
180
|
+
for (let i = 0; i < numChunks; i++) {
|
|
181
|
+
const start = i * chunkSize;
|
|
182
|
+
const end = Math.min(start + chunkSize, totalSize);
|
|
183
|
+
const chunkBlob = blob.slice(start, end);
|
|
184
|
+
|
|
185
|
+
const chunkResponse = new Response(chunkBlob, {
|
|
186
|
+
headers: {
|
|
187
|
+
'Content-Type': contentType,
|
|
188
|
+
'Content-Length': chunkBlob.size,
|
|
189
|
+
'X-Chunk-Index': i,
|
|
190
|
+
'X-Total-Chunks': numChunks
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await this.cache.put(`${cacheKey}/chunk-${i}`, chunkResponse);
|
|
195
|
+
|
|
196
|
+
if ((i + 1) % 5 === 0 || i === numChunks - 1) {
|
|
197
|
+
this.log.info(`Stored chunk ${i + 1}/${numChunks} (${formatBytes(chunkBlob.size)})`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// All chunks stored — mark metadata complete
|
|
202
|
+
metadata.complete = true;
|
|
203
|
+
await this.cache.put(`${cacheKey}/metadata`, new Response(
|
|
204
|
+
JSON.stringify(metadata),
|
|
205
|
+
{ headers: { 'Content-Type': 'application/json' } }
|
|
206
|
+
));
|
|
207
|
+
this.metadataCache.set(cacheKey, metadata);
|
|
208
|
+
|
|
209
|
+
this.log.info(`Chunked storage complete: ${cacheKey}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get metadata for chunked file.
|
|
214
|
+
* Checks in-memory cache first to avoid Cache API I/O on hot paths.
|
|
215
|
+
* @param {string} cacheKey - Base cache key
|
|
216
|
+
* @returns {Promise<Object|null>}
|
|
217
|
+
*/
|
|
218
|
+
async getMetadata(cacheKey) {
|
|
219
|
+
// Fast path: in-memory cache
|
|
220
|
+
const cached = this.metadataCache.get(cacheKey);
|
|
221
|
+
if (cached) return cached;
|
|
222
|
+
|
|
223
|
+
if (!this.cache) await this.init();
|
|
224
|
+
|
|
225
|
+
const response = await this.cache.match(`${cacheKey}/metadata`);
|
|
226
|
+
if (!response) return null;
|
|
227
|
+
|
|
228
|
+
const text = await response.text();
|
|
229
|
+
const metadata = JSON.parse(text);
|
|
230
|
+
|
|
231
|
+
// Populate in-memory cache
|
|
232
|
+
this.metadataCache.set(cacheKey, metadata);
|
|
233
|
+
return metadata;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Update metadata both in Cache API and in-memory cache
|
|
238
|
+
* @param {string} cacheKey - Base cache key
|
|
239
|
+
* @param {Object} metadata - Metadata to store
|
|
240
|
+
*/
|
|
241
|
+
async updateMetadata(cacheKey, metadata) {
|
|
242
|
+
if (!this.cache) await this.init();
|
|
243
|
+
await this.cache.put(`${cacheKey}/metadata`, new Response(
|
|
244
|
+
JSON.stringify(metadata),
|
|
245
|
+
{ headers: { 'Content-Type': 'application/json' } }
|
|
246
|
+
));
|
|
247
|
+
this.metadataCache.set(cacheKey, metadata);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Check if file is stored as chunks
|
|
252
|
+
* @param {string} cacheKey - Base cache key
|
|
253
|
+
* @returns {Promise<boolean>}
|
|
254
|
+
*/
|
|
255
|
+
async isChunked(cacheKey) {
|
|
256
|
+
const metadata = await this.getMetadata(cacheKey);
|
|
257
|
+
return metadata?.chunked === true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Get specific chunk
|
|
262
|
+
* @param {string} cacheKey - Base cache key
|
|
263
|
+
* @param {number} chunkIndex - Chunk index
|
|
264
|
+
* @returns {Promise<Response|null>}
|
|
265
|
+
*/
|
|
266
|
+
async getChunk(cacheKey, chunkIndex) {
|
|
267
|
+
if (!this.cache) await this.init();
|
|
268
|
+
return await this.cache.match(`${cacheKey}/chunk-${chunkIndex}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Worker logger and chunk configuration
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Simple logger for Service Worker context.
|
|
7
|
+
* Uses console but can be configured via self.swLogLevel.
|
|
8
|
+
*/
|
|
9
|
+
export class SWLogger {
|
|
10
|
+
constructor(name) {
|
|
11
|
+
this.name = name;
|
|
12
|
+
// Default level: INFO (can be changed via self.swLogLevel = 'DEBUG')
|
|
13
|
+
this.level = (typeof self !== 'undefined' && self.swLogLevel) || 'INFO';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
debug(...args) {
|
|
17
|
+
if (this.level === 'DEBUG') {
|
|
18
|
+
console.log(`[${this.name}] DEBUG:`, ...args);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
info(...args) {
|
|
23
|
+
if (this.level === 'DEBUG' || this.level === 'INFO') {
|
|
24
|
+
console.log(`[${this.name}]`, ...args);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
warn(...args) {
|
|
29
|
+
console.warn(`[${this.name}]`, ...args);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
error(...args) {
|
|
33
|
+
console.error(`[${this.name}]`, ...args);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Calculate optimal chunk size based on available device memory.
|
|
39
|
+
* Returns configuration for chunk streaming, blob caching, and download concurrency.
|
|
40
|
+
*
|
|
41
|
+
* @param {{ info: Function }} [log] - Optional logger (created internally if not provided)
|
|
42
|
+
* @returns {{ chunkSize: number, blobCacheSize: number, threshold: number, concurrency: number }}
|
|
43
|
+
*/
|
|
44
|
+
export function calculateChunkConfig(log) {
|
|
45
|
+
if (!log) log = new SWLogger('ChunkConfig');
|
|
46
|
+
|
|
47
|
+
// Try to detect device memory (Chrome only for now)
|
|
48
|
+
const deviceMemoryGB = (typeof navigator !== 'undefined' && navigator.deviceMemory) || null;
|
|
49
|
+
|
|
50
|
+
// Fallback: estimate from user agent
|
|
51
|
+
let estimatedRAM_GB = 4; // Default assumption
|
|
52
|
+
|
|
53
|
+
if (deviceMemoryGB) {
|
|
54
|
+
estimatedRAM_GB = deviceMemoryGB;
|
|
55
|
+
log.info('Detected device memory:', deviceMemoryGB, 'GB');
|
|
56
|
+
} else if (typeof navigator !== 'undefined') {
|
|
57
|
+
// Parse user agent for hints
|
|
58
|
+
const ua = navigator.userAgent.toLowerCase();
|
|
59
|
+
if (ua.includes('raspberry pi') || ua.includes('armv6')) {
|
|
60
|
+
estimatedRAM_GB = 0.5; // Pi Zero
|
|
61
|
+
log.info('Detected Pi Zero (512 MB RAM estimated)');
|
|
62
|
+
} else if (ua.includes('armv7')) {
|
|
63
|
+
estimatedRAM_GB = 1; // Pi 3/4
|
|
64
|
+
log.info('Detected ARM device (1 GB RAM estimated)');
|
|
65
|
+
} else {
|
|
66
|
+
log.info('Using default RAM estimate:', estimatedRAM_GB, 'GB');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Configure based on RAM - chunk size, cache, threshold, AND concurrency
|
|
71
|
+
let chunkSize, blobCacheSize, threshold, concurrency;
|
|
72
|
+
|
|
73
|
+
if (estimatedRAM_GB <= 0.5) {
|
|
74
|
+
// Pi Zero (512 MB) - very conservative
|
|
75
|
+
chunkSize = 10 * 1024 * 1024;
|
|
76
|
+
blobCacheSize = 25;
|
|
77
|
+
threshold = 25 * 1024 * 1024;
|
|
78
|
+
concurrency = 1;
|
|
79
|
+
log.info('Low-memory config: 10 MB chunks, 25 MB cache, 1 concurrent download');
|
|
80
|
+
} else if (estimatedRAM_GB <= 1) {
|
|
81
|
+
// 1 GB RAM (Pi 3) - conservative
|
|
82
|
+
chunkSize = 20 * 1024 * 1024;
|
|
83
|
+
blobCacheSize = 50;
|
|
84
|
+
threshold = 50 * 1024 * 1024;
|
|
85
|
+
concurrency = 2;
|
|
86
|
+
log.info('1GB-RAM config: 20 MB chunks, 50 MB cache, 2 concurrent downloads');
|
|
87
|
+
} else if (estimatedRAM_GB <= 2) {
|
|
88
|
+
// 2 GB RAM - moderate
|
|
89
|
+
chunkSize = 30 * 1024 * 1024;
|
|
90
|
+
blobCacheSize = 100;
|
|
91
|
+
threshold = 75 * 1024 * 1024;
|
|
92
|
+
concurrency = 2;
|
|
93
|
+
log.info('2GB-RAM config: 30 MB chunks, 100 MB cache, 2 concurrent downloads');
|
|
94
|
+
} else if (estimatedRAM_GB <= 4) {
|
|
95
|
+
// 4 GB RAM - default
|
|
96
|
+
chunkSize = 50 * 1024 * 1024;
|
|
97
|
+
blobCacheSize = 200;
|
|
98
|
+
threshold = 100 * 1024 * 1024;
|
|
99
|
+
concurrency = 4;
|
|
100
|
+
log.info('4GB-RAM config: 50 MB chunks, 200 MB cache, 4 concurrent downloads');
|
|
101
|
+
} else {
|
|
102
|
+
// 8+ GB RAM - generous
|
|
103
|
+
chunkSize = 100 * 1024 * 1024;
|
|
104
|
+
blobCacheSize = 500;
|
|
105
|
+
threshold = 200 * 1024 * 1024;
|
|
106
|
+
concurrency = 6;
|
|
107
|
+
log.info('High-RAM config: 100 MB chunks, 500 MB cache, 6 concurrent downloads');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { chunkSize, blobCacheSize, threshold, concurrency };
|
|
111
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,23 +1,7 @@
|
|
|
1
|
-
// @xiboplayer/sw - Service Worker toolkit
|
|
2
|
-
export
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
try {
|
|
9
|
-
const registration = await navigator.serviceWorker.register(swUrl, options);
|
|
10
|
-
console.log('Service Worker registered:', registration);
|
|
11
|
-
return registration;
|
|
12
|
-
} catch (error) {
|
|
13
|
-
console.error('Service Worker registration failed:', error);
|
|
14
|
-
throw error;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export async function unregisterServiceWorker() {
|
|
19
|
-
if (!('serviceWorker' in navigator)) return false;
|
|
20
|
-
|
|
21
|
-
const registration = await navigator.serviceWorker.ready;
|
|
22
|
-
return await registration.unregister();
|
|
23
|
-
}
|
|
1
|
+
// @xiboplayer/sw - Service Worker toolkit for chunk streaming and offline caching
|
|
2
|
+
export { CacheManager } from './cache-manager.js';
|
|
3
|
+
export { BlobCache } from './blob-cache.js';
|
|
4
|
+
export { RequestHandler } from './request-handler.js';
|
|
5
|
+
export { MessageHandler } from './message-handler.js';
|
|
6
|
+
export { extractMediaIdsFromXlf } from './xlf-parser.js';
|
|
7
|
+
export { calculateChunkConfig, SWLogger } from './chunk-config.js';
|