@xiboplayer/cache 0.4.0 → 0.4.3

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
@@ -11,6 +11,8 @@ Manages media downloads and offline storage for Xibo players:
11
11
  - **MD5 verification** — integrity checking with CRC32-based skip optimization
12
12
  - **Download queue** — flat queue with barriers for layout-ordered downloading
13
13
  - **CacheProxy** — browser-side proxy that communicates with a Service Worker backend
14
+ - **Widget data via enriched RequiredFiles** — RSS/dataset widget data is fetched through server-side enriched RequiredFiles paths (CMS adds download URLs), not via client-side pre-fetching
15
+ - **Dynamic BASE path** — widget HTML `<base>` tag uses a dynamic path within the Service Worker scope for correct relative URL resolution
14
16
 
15
17
  ## Installation
16
18
 
@@ -363,6 +363,18 @@ it('should download and cache files', async () => {
363
363
  const blob = await cacheProxy.getFile('media', id);
364
364
  ```
365
365
 
366
+ ## Widget Data Download Flow
367
+
368
+ Widget data for RSS feeds and dataset widgets is handled server-side. The CMS enriches
369
+ the RequiredFiles response with absolute download URLs for widget data files. These are
370
+ downloaded through the normal CacheProxy/Service Worker pipeline alongside regular media,
371
+ rather than being fetched client-side by the player. This ensures widget data is available
372
+ offline and benefits from the same parallel chunk download and caching infrastructure.
373
+
374
+ Widget HTML served from cache uses a dynamic `<base>` tag pointing to the Service Worker
375
+ scope path, ensuring relative URLs within widget HTML resolve correctly regardless of the
376
+ player's deployment path.
377
+
366
378
  ### For New Platforms
367
379
 
368
380
  Simply use CacheProxy from the start:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/cache",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
4
4
  "description": "Offline caching and download management with parallel chunk downloads",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "spark-md5": "^3.0.2",
15
- "@xiboplayer/utils": "0.4.0"
15
+ "@xiboplayer/utils": "0.4.3"
16
16
  },
17
17
  "devDependencies": {
18
18
  "vitest": "^2.0.0",
@@ -56,6 +56,14 @@ export class CacheAnalyzer {
56
56
  for (const file of cachedFiles) {
57
57
  if (requiredIds.has(String(file.id))) {
58
58
  required.push(file);
59
+ } else if (file.type === 'widget') {
60
+ // Widget HTML IDs are "layoutId/regionId/widgetId" — check parent layout
61
+ const parentLayoutId = String(file.id).split('/')[0];
62
+ if (requiredIds.has(parentLayoutId)) {
63
+ required.push(file);
64
+ } else {
65
+ orphaned.push(file);
66
+ }
59
67
  } else {
60
68
  orphaned.push(file);
61
69
  }
@@ -170,6 +170,20 @@ describe('CacheAnalyzer', () => {
170
170
  vi.unstubAllGlobals();
171
171
  });
172
172
 
173
+ it('should treat widget HTML as required when parent layout is required', async () => {
174
+ mockCache._addFile({ id: '470', type: 'layout', size: 500, cachedAt: 100 });
175
+ mockCache._addFile({ id: '470/213/182', type: 'widget', size: 0, cachedAt: 0 });
176
+ mockCache._addFile({ id: '470/215/184', type: 'widget', size: 0, cachedAt: 0 });
177
+ mockCache._addFile({ id: '99/10/5', type: 'widget', size: 0, cachedAt: 0 });
178
+
179
+ const report = await analyzer.analyze([{ id: '470', type: 'layout' }]);
180
+
181
+ // Layout 470 + its 2 widgets = 3 required; widget for layout 99 = orphaned
182
+ expect(report.files.required).toBe(3);
183
+ expect(report.files.orphaned).toBe(1);
184
+ expect(report.orphaned[0].id).toBe('99/10/5');
185
+ });
186
+
173
187
  it('should handle files with missing size or cachedAt', async () => {
174
188
  mockCache._addFile({ id: '1', type: 'media' }); // no size, no cachedAt
175
189
 
@@ -97,6 +97,20 @@ export function isUrlExpired(url, graceSeconds = 30) {
97
97
  return (Date.now() / 1000) >= (expiry - graceSeconds);
98
98
  }
99
99
 
100
+ /**
101
+ * Rewrite an absolute CMS URL through the local proxy when running behind
102
+ * the proxy server (Chromium kiosk or Electron).
103
+ * Detection: SW/window on localhost:8765 = proxy mode.
104
+ */
105
+ export function rewriteUrlForProxy(url) {
106
+ if (!url.startsWith('http')) return url;
107
+ const loc = typeof self !== 'undefined' ? self.location : undefined;
108
+ if (!loc || loc.hostname !== 'localhost' || loc.port !== '8765') return url;
109
+ const parsed = new URL(url);
110
+ const cmsOrigin = parsed.origin;
111
+ return `/file-proxy?cms=${encodeURIComponent(cmsOrigin)}&url=${encodeURIComponent(parsed.pathname + parsed.search)}`;
112
+ }
113
+
100
114
  /**
101
115
  * DownloadTask - Single HTTP fetch unit
102
116
  *
@@ -120,7 +134,7 @@ export class DownloadTask {
120
134
  if (isUrlExpired(url)) {
121
135
  throw new Error(`URL expired for ${this.fileInfo.type}/${this.fileInfo.id} — waiting for fresh URL from next collection cycle`);
122
136
  }
123
- return url;
137
+ return rewriteUrlForProxy(url);
124
138
  }
125
139
 
126
140
  async start() {
@@ -207,7 +221,7 @@ export class FileDownload {
207
221
  if (isUrlExpired(url)) {
208
222
  throw new Error(`URL expired for ${this.fileInfo.type}/${this.fileInfo.id} — waiting for fresh URL from next collection cycle`);
209
223
  }
210
- return url;
224
+ return rewriteUrlForProxy(url);
211
225
  }
212
226
 
213
227
  wait() {
@@ -35,18 +35,20 @@ export async function cacheWidgetHtml(layoutId, regionId, mediaId, html) {
35
35
  const cache = await caches.open(CACHE_NAME);
36
36
 
37
37
  // Inject <base> tag to fix relative paths for widget dependencies
38
- // Widget HTML has relative paths like "bundle.min.js" that should resolve to /player/cache/media/
39
- const baseTag = '<base href="/player/cache/media/">';
38
+ // Widget HTML has relative paths like "bundle.min.js" that should resolve to cache/media/
39
+ const baseTag = `<base href="${BASE}/cache/media/">`;
40
40
  let modifiedHtml = html;
41
41
 
42
- // Insert base tag after <head> opening tag
43
- if (html.includes('<head>')) {
44
- modifiedHtml = html.replace('<head>', '<head>' + baseTag);
45
- } else if (html.includes('<HEAD>')) {
46
- modifiedHtml = html.replace('<HEAD>', '<HEAD>' + baseTag);
47
- } else {
48
- // No head tag, prepend base tag
49
- modifiedHtml = baseTag + html;
42
+ // Insert base tag after <head> opening tag (skip if already present)
43
+ if (!html.includes('<base ')) {
44
+ if (html.includes('<head>')) {
45
+ modifiedHtml = html.replace('<head>', '<head>' + baseTag);
46
+ } else if (html.includes('<HEAD>')) {
47
+ modifiedHtml = html.replace('<HEAD>', '<HEAD>' + baseTag);
48
+ } else {
49
+ // No head tag, prepend base tag
50
+ modifiedHtml = baseTag + html;
51
+ }
50
52
  }
51
53
 
52
54
  // Rewrite absolute CMS signed URLs to local cache paths
@@ -65,10 +67,12 @@ export async function cacheWidgetHtml(layoutId, regionId, mediaId, html) {
65
67
  // CMS global-elements.xml uses {{alignId}} {{valignId}} which produces
66
68
  // invalid CSS (empty value) when alignment is not configured
67
69
  const cssFixTag = '<style>img,video{object-position:center center}</style>';
68
- if (modifiedHtml.includes('</head>')) {
69
- modifiedHtml = modifiedHtml.replace('</head>', cssFixTag + '</head>');
70
- } else if (modifiedHtml.includes('</HEAD>')) {
71
- modifiedHtml = modifiedHtml.replace('</HEAD>', cssFixTag + '</HEAD>');
70
+ if (!modifiedHtml.includes('object-position:center center')) {
71
+ if (modifiedHtml.includes('</head>')) {
72
+ modifiedHtml = modifiedHtml.replace('</head>', cssFixTag + '</head>');
73
+ } else if (modifiedHtml.includes('</HEAD>')) {
74
+ modifiedHtml = modifiedHtml.replace('</HEAD>', cssFixTag + '</HEAD>');
75
+ }
72
76
  }
73
77
 
74
78
  // Rewrite Interactive Control hostAddress to SW-interceptable path
@@ -80,7 +84,7 @@ export async function cacheWidgetHtml(layoutId, regionId, mediaId, html) {
80
84
  `hostAddress: "${BASE}/ic"`
81
85
  );
82
86
 
83
- log.info('Injected base tag and rewrote CMS URLs in widget HTML');
87
+ log.info('Injected base tag and rewrote CMS/data URLs in widget HTML');
84
88
 
85
89
  // Construct full URL for cache storage
86
90
  const cacheUrl = new URL(cacheKey, window.location.origin);
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Widget HTML caching tests
3
+ *
4
+ * Tests the URL rewriting and base tag injection that cacheWidgetHtml performs.
5
+ * These are critical for widget rendering — CMS provides HTML with absolute
6
+ * signed URLs that must be rewritten to local cache paths.
7
+ *
8
+ * Real-world scenario (RSS ticker in layout 472, region 223, widget 193):
9
+ * 1. CMS getResource returns HTML with signed URLs for bundle.min.js, fonts.css
10
+ * 2. SW download manager may cache this raw HTML before the main thread processes it
11
+ * 3. cacheWidgetHtml must rewrite CMS URLs → /cache/static/ and fetch the resources
12
+ * 4. Widget iframe loads, SW serves bundle.min.js and fonts.css from static cache
13
+ * 5. bundle.min.js runs getWidgetData → $.ajax("193.json") → SW serves from media cache
14
+ */
15
+
16
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
17
+ import { cacheWidgetHtml } from './widget-html.js';
18
+
19
+ // --- Realistic CMS HTML templates (based on actual CMS output) ---
20
+
21
+ const CMS_BASE = 'https://displays.superpantalles.com';
22
+ const SIGNED_PARAMS = 'displayId=152&type=P&itemId=1&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260222T000000Z&X-Amz-Expires=1771803983&X-Amz-SignedHeaders=host&X-Amz-Signature=abc123';
23
+
24
+ /**
25
+ * Simulates actual CMS RSS ticker widget HTML (layout 472, region 223, widget 193).
26
+ * The CMS generates this via getResource — it includes:
27
+ * - Signed URLs for bundle.min.js and fonts.css (must be rewritten)
28
+ * - Relative data URL "193.json" resolved via <base> tag
29
+ * - Interactive Control (xiboIC) with hostAddress pointing at CMS
30
+ */
31
+ function makeRssTickerHtml() {
32
+ return `<!DOCTYPE html>
33
+ <html>
34
+ <head>
35
+ <meta charset="utf-8">
36
+ <title>RSS Ticker</title>
37
+ <script src="${CMS_BASE}/pwa/file?file=bundle.min.js&fileType=bundle&${SIGNED_PARAMS}"></script>
38
+ <link rel="stylesheet" href="${CMS_BASE}/pwa/file?file=fonts.css&fileType=fontCss&${SIGNED_PARAMS}">
39
+ <style>.rss-item { padding: 10px; }</style>
40
+ </head>
41
+ <body>
42
+ <div id="content">
43
+ <script>
44
+ var currentWidget = { url: "193.json", duration: 5 };
45
+ var options = {hostAddress: "${CMS_BASE}"};
46
+ xiboIC.init(options);
47
+ function getWidgetData() {
48
+ $.ajax({ url: currentWidget.url, dataType: "json" });
49
+ }
50
+ </script>
51
+ </div>
52
+ </body>
53
+ </html>`;
54
+ }
55
+
56
+ /** PDF widget (layout 472, region 221, widget 190) — simpler, no data URL */
57
+ function makePdfWidgetHtml() {
58
+ return `<!DOCTYPE html>
59
+ <html>
60
+ <head>
61
+ <meta charset="utf-8">
62
+ <script src="${CMS_BASE}/pwa/file?file=bundle.min.js&fileType=bundle&${SIGNED_PARAMS}"></script>
63
+ <link rel="stylesheet" href="${CMS_BASE}/pwa/file?file=fonts.css&fileType=fontCss&${SIGNED_PARAMS}"></link>
64
+ </head>
65
+ <body>
66
+ <object data="11.pdf" type="application/pdf" width="100%" height="100%"></object>
67
+ </body>
68
+ </html>`;
69
+ }
70
+
71
+ /** Clock widget — uses xmds.php endpoint (older CMS versions) */
72
+ function makeClockWidgetHtml() {
73
+ return `<html>
74
+ <head>
75
+ <script src="${CMS_BASE}/xmds.php?file=bundle.min.js&${SIGNED_PARAMS}"></script>
76
+ <link rel="stylesheet" href="${CMS_BASE}/xmds.php?file=fonts.css&${SIGNED_PARAMS}">
77
+ </head>
78
+ <body><div class="clock"></div></body>
79
+ </html>`;
80
+ }
81
+
82
+ // --- Mock Cache API ---
83
+
84
+ const cacheStore = new Map();
85
+ const mockCache = {
86
+ put: vi.fn(async (url, response) => {
87
+ const key = typeof url === 'string' ? url : url.toString();
88
+ const text = await response.clone().text();
89
+ cacheStore.set(key, text);
90
+ }),
91
+ match: vi.fn(async (key) => {
92
+ const url = typeof key === 'string' ? key : (key.url || key.toString());
93
+ const text = cacheStore.get(url);
94
+ return text ? new Response(text) : undefined;
95
+ }),
96
+ };
97
+
98
+ const fetchedUrls = [];
99
+ global.fetch = vi.fn(async (url) => {
100
+ fetchedUrls.push(url);
101
+ if (url.includes('bundle.min.js')) {
102
+ return new Response('var xiboIC = { init: function(){} };', { status: 200 });
103
+ }
104
+ if (url.includes('fonts.css')) {
105
+ return new Response(`@font-face { font-family: "Poppins"; src: url("${CMS_BASE}/pwa/file?file=Poppins-Regular.ttf&${SIGNED_PARAMS}"); }`, { status: 200 });
106
+ }
107
+ if (url.includes('.ttf') || url.includes('.woff')) {
108
+ return new Response(new Blob([new Uint8Array(100)]), { status: 200 });
109
+ }
110
+ return new Response('', { status: 404 });
111
+ });
112
+
113
+ global.caches = {
114
+ open: vi.fn(async () => mockCache),
115
+ };
116
+
117
+ // --- Tests ---
118
+
119
+ describe('cacheWidgetHtml', () => {
120
+ beforeEach(() => {
121
+ cacheStore.clear();
122
+ vi.clearAllMocks();
123
+ fetchedUrls.length = 0;
124
+ });
125
+
126
+ // --- Base tag injection ---
127
+
128
+ describe('base tag injection', () => {
129
+ it('injects <base> tag after <head>', async () => {
130
+ const html = '<html><head><title>Widget</title></head><body>content</body></html>';
131
+ await cacheWidgetHtml('472', '223', '193', html);
132
+
133
+ const stored = cacheStore.values().next().value;
134
+ expect(stored).toContain('<base href=');
135
+ expect(stored).toContain('/cache/media/">');
136
+ });
137
+
138
+ it('injects <base> tag when no <head> tag exists', async () => {
139
+ const html = '<div>no head tag</div>';
140
+ await cacheWidgetHtml('472', '223', '193', html);
141
+
142
+ const stored = cacheStore.values().next().value;
143
+ expect(stored).toContain('<base href=');
144
+ });
145
+ });
146
+
147
+ // --- RSS ticker: layout 472, region 223, widget 193 ---
148
+
149
+ describe('RSS ticker (layout 472 / region 223 / widget 193)', () => {
150
+ it('rewrites bundle.min.js and fonts.css signed URLs', async () => {
151
+ await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
152
+
153
+ const stored = cacheStore.values().next().value;
154
+ expect(stored).not.toContain(CMS_BASE);
155
+ expect(stored).toContain('/cache/static/bundle.min.js');
156
+ expect(stored).toContain('/cache/static/fonts.css');
157
+ });
158
+
159
+ it('preserves the data URL (193.json) for SW interception', async () => {
160
+ await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
161
+
162
+ const stored = cacheStore.values().next().value;
163
+ // 193.json is relative — resolved by <base> tag, not rewritten
164
+ expect(stored).toContain('"193.json"');
165
+ });
166
+
167
+ it('rewrites xiboIC hostAddress from CMS to local path', async () => {
168
+ await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
169
+
170
+ const stored = cacheStore.values().next().value;
171
+ expect(stored).not.toContain(`hostAddress: "${CMS_BASE}"`);
172
+ expect(stored).toContain('/ic"');
173
+ });
174
+
175
+ it('fetches bundle.min.js and fonts.css from CMS for local caching', async () => {
176
+ await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
177
+
178
+ const bundleFetched = fetchedUrls.some(u => u.includes('bundle.min.js'));
179
+ const fontsFetched = fetchedUrls.some(u => u.includes('fonts.css'));
180
+ expect(bundleFetched).toBe(true);
181
+ expect(fontsFetched).toBe(true);
182
+ });
183
+
184
+ it('stores processed HTML at correct cache key', async () => {
185
+ await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
186
+
187
+ const keys = [...cacheStore.keys()];
188
+ const widgetKey = keys.find(k => k.includes('/cache/widget/472/223/193'));
189
+ expect(widgetKey).toBeTruthy();
190
+ });
191
+
192
+ it('caches font files referenced in fonts.css', async () => {
193
+ await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
194
+
195
+ // fonts.css contains url("...Poppins-Regular.ttf") which should be fetched
196
+ const fontFetched = fetchedUrls.some(u => u.includes('Poppins-Regular.ttf'));
197
+ expect(fontFetched).toBe(true);
198
+ });
199
+ });
200
+
201
+ // --- PDF widget: layout 472, region 221, widget 190 ---
202
+
203
+ describe('PDF widget (layout 472 / region 221 / widget 190)', () => {
204
+ it('rewrites CMS URLs and preserves relative PDF path', async () => {
205
+ await cacheWidgetHtml('472', '221', '190', makePdfWidgetHtml());
206
+
207
+ const stored = cacheStore.values().next().value;
208
+ expect(stored).not.toContain(CMS_BASE);
209
+ expect(stored).toContain('/cache/static/bundle.min.js');
210
+ // PDF data attribute "11.pdf" is relative — resolved by <base> tag
211
+ expect(stored).toContain('"11.pdf"');
212
+ });
213
+
214
+ it('stores at correct widget cache key with layout/region/media IDs', async () => {
215
+ await cacheWidgetHtml('472', '221', '190', makePdfWidgetHtml());
216
+
217
+ const keys = [...cacheStore.keys()];
218
+ expect(keys.some(k => k.includes('/cache/widget/472/221/190'))).toBe(true);
219
+ });
220
+ });
221
+
222
+ // --- Clock widget: xmds.php endpoint (legacy) ---
223
+
224
+ describe('clock widget with xmds.php URLs', () => {
225
+ it('rewrites xmds.php signed URLs to local cache paths', async () => {
226
+ await cacheWidgetHtml('1', '1', '1', makeClockWidgetHtml());
227
+
228
+ const stored = cacheStore.values().next().value;
229
+ expect(stored).not.toContain('xmds.php');
230
+ expect(stored).toContain('/cache/static/bundle.min.js');
231
+ expect(stored).toContain('/cache/static/fonts.css');
232
+ });
233
+ });
234
+
235
+ // --- Idempotency (regression: duplicate base/style tags) ---
236
+
237
+ describe('idempotency', () => {
238
+ it('does not add duplicate <base> tags on re-processing', async () => {
239
+ await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
240
+ const firstPass = cacheStore.values().next().value;
241
+
242
+ await cacheWidgetHtml('472', '223', '193', firstPass);
243
+ const secondPass = cacheStore.values().next().value;
244
+
245
+ expect(secondPass).toBe(firstPass);
246
+ });
247
+
248
+ it('does not add duplicate CSS fix tags on re-processing', async () => {
249
+ const html = '<html><head></head><body></body></html>';
250
+ await cacheWidgetHtml('472', '223', '193', html);
251
+ const firstPass = cacheStore.values().next().value;
252
+
253
+ await cacheWidgetHtml('472', '223', '193', firstPass);
254
+ const secondPass = cacheStore.values().next().value;
255
+
256
+ const baseCount = (secondPass.match(/<base /g) || []).length;
257
+ const styleCount = (secondPass.match(/object-position:center center/g) || []).length;
258
+ expect(baseCount).toBe(1);
259
+ expect(styleCount).toBe(1);
260
+ });
261
+ });
262
+
263
+ // --- SW pre-cache scenario (the bug we fixed) ---
264
+
265
+ describe('SW pre-cached raw HTML (regression)', () => {
266
+ it('processes raw HTML that SW cached without rewriting', async () => {
267
+ // Simulate: SW downloads getResource HTML and caches it raw
268
+ const rawCmsHtml = makeRssTickerHtml();
269
+
270
+ // Main thread finds it in cache and re-processes
271
+ await cacheWidgetHtml('472', '223', '193', rawCmsHtml);
272
+
273
+ const stored = cacheStore.values().next().value;
274
+ // CMS URLs must be rewritten even though HTML came from cache
275
+ expect(stored).not.toContain(CMS_BASE);
276
+ expect(stored).toContain('/cache/static/bundle.min.js');
277
+ expect(stored).toContain('/cache/static/fonts.css');
278
+ expect(stored).toContain('<base href=');
279
+ });
280
+
281
+ it('handles different widgets in different regions of same layout', async () => {
282
+ // Layout 472 has region 221 (PDF) and region 223 (RSS ticker)
283
+ await cacheWidgetHtml('472', '221', '190', makePdfWidgetHtml());
284
+ await cacheWidgetHtml('472', '223', '193', makeRssTickerHtml());
285
+
286
+ const keys = [...cacheStore.keys()];
287
+ const pdfKey = keys.find(k => k.includes('/cache/widget/472/221/190'));
288
+ const rssKey = keys.find(k => k.includes('/cache/widget/472/223/193'));
289
+ expect(pdfKey).toBeTruthy();
290
+ expect(rssKey).toBeTruthy();
291
+
292
+ // Both should have CMS URLs rewritten
293
+ expect(cacheStore.get(pdfKey)).not.toContain(CMS_BASE);
294
+ expect(cacheStore.get(rssKey)).not.toContain(CMS_BASE);
295
+ });
296
+ });
297
+ });