@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 CHANGED
@@ -1,15 +1,16 @@
1
- # @xiboplayer/sw Documentation
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 utilities for:
7
+ Provides Service Worker building blocks for Xibo players:
8
8
 
9
- - **Chunk streaming** - Progressive media download
10
- - **Cache-first strategy** - Fast offline access
11
- - **Background sync** - Resilient updates
12
- - **Update mechanism** - Seamless SW updates
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
- - HTTP 206 Partial Content support
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
- - [@xiboplayer/cache](../../cache/docs/) - Cache management
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
- **Package Version**: 1.0.0
32
+ **Part of the [XiboPlayer SDK](https://github.com/linuxnow/xiboplayer)**
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "@xiboplayer/sw",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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
- "./worker": "./src/worker.js"
9
+ "./utils": "./src/sw-utils.js"
10
+ },
11
+ "dependencies": {
12
+ "@xiboplayer/cache": "0.3.0"
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 async function registerServiceWorker(swUrl, options = {}) {
3
- if (!('serviceWorker' in navigator)) {
4
- console.warn('Service Workers not supported');
5
- return null;
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';