From 2418298d77150a84162af2ad9b7fa5e9f1b24792 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 17 Apr 2026 12:26:04 -0400 Subject: [PATCH] feat: PWA auto-update detection for injected script server.js: hash live-search.js into INJECT_VERSION, expose /api/custom/inject-version, set no-store on HTML so inline version tag stays fresh. live-search.js: compare baked window.__LS_VERSION vs server version every 2 min + on visibility change; show banner, auto-unregister service worker, clear caches, and reload when a new version ships. Co-Authored-By: Claude Opus 4.7 (1M context) --- search-app/live-search.js | 73 +++++++++++++++++++++++++++++++++++++++ search-app/server.js | 28 +++++++++++++-- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/search-app/live-search.js b/search-app/live-search.js index b0fe736..9eacb7d 100644 --- a/search-app/live-search.js +++ b/search-app/live-search.js @@ -741,4 +741,77 @@ }); console.log('[live-search] Immich live search with map view loaded'); + + // --- PWA auto-update detection --- + // The proxy bakes a version hash into window.__LS_VERSION on each HTML + // response. We poll /api/custom/inject-version and, if the server reports a + // newer hash, we nuke caches + unregister the service worker + reload so the + // PWA picks up the new HTML (with the new inline script). + (function setupAutoUpdate() { + const myVersion = window.__LS_VERSION; + if (!myVersion) return; // older HTML without version — skip + console.log('[live-search] version:', myVersion); + + let updating = false; + async function forceUpdate() { + if (updating) return; + updating = true; + try { + if ('serviceWorker' in navigator) { + const regs = await navigator.serviceWorker.getRegistrations(); + await Promise.all(regs.map(r => r.unregister().catch(() => null))); + } + if (window.caches) { + const keys = await caches.keys(); + await Promise.all(keys.map(k => caches.delete(k).catch(() => null))); + } + } catch (e) { + console.warn('[live-search] cache clear failed', e); + } + location.reload(); + } + + function showBanner() { + if (document.getElementById('ls-update-banner')) return; + const b = document.createElement('div'); + b.id = 'ls-update-banner'; + b.innerHTML = + 'New version available' + + ''; + b.style.cssText = [ + 'position:fixed', 'bottom:16px', 'left:50%', + 'transform:translateX(-50%)', 'z-index:100000', + 'background:#e94560', 'color:#fff', 'padding:10px 14px', + 'border-radius:8px', 'box-shadow:0 6px 20px rgba(0,0,0,0.4)', + 'display:flex', 'gap:10px', 'align-items:center', + 'font-size:14px', 'font-family:system-ui,sans-serif' + ].join(';'); + b.querySelector('#ls-update-btn').style.cssText = + 'background:#fff;color:#e94560;border:none;border-radius:6px;' + + 'padding:5px 10px;font-weight:600;cursor:pointer;'; + b.querySelector('#ls-update-btn').addEventListener('click', forceUpdate); + document.body.appendChild(b); + // Auto-apply after 8s if user ignores banner + setTimeout(() => { if (document.body.contains(b)) forceUpdate(); }, 8000); + } + + async function checkVersion() { + try { + const r = await fetch('/api/custom/inject-version', { cache: 'no-store' }); + if (!r.ok) return; + const { version } = await r.json(); + if (version && version !== myVersion) { + console.log('[live-search] new version detected:', version); + showBanner(); + } + } catch {} + } + + // Check on load, on visibility change, and every 2 minutes + checkVersion(); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') checkVersion(); + }); + setInterval(checkVersion, 2 * 60 * 1000); + })(); })(); diff --git a/search-app/server.js b/search-app/server.js index d1486b6..0eb6023 100644 --- a/search-app/server.js +++ b/search-app/server.js @@ -2,6 +2,7 @@ const http = require('http'); const net = require('net'); const fs = require('fs'); const path = require('path'); +const crypto = require('crypto'); const PORT = process.env.PORT || 3000; const IMMICH_URL = process.env.IMMICH_URL || 'http://immich-server:2283'; @@ -10,9 +11,17 @@ const immichParsed = new URL(IMMICH_URL); // Timeouts for large uploads (10 minutes) const UPLOAD_TIMEOUT = 10 * 60 * 1000; -// Load the custom JS to inject +// Load the custom JS to inject. Version hash lets PWAs detect stale caches. const customJS = fs.readFileSync(path.join(__dirname, 'live-search.js'), 'utf8'); -const SCRIPT_TAG = ``; +const INJECT_VERSION = crypto + .createHash('sha256') + .update(customJS) + .digest('hex') + .slice(0, 12); +const SCRIPT_TAG = + `` + + ``; +console.log(`Injected script version: ${INJECT_VERSION}`); // Helper: make an internal request to Immich (for small JSON requests only) function immichRequest(method, apiPath, headers, body) { @@ -42,6 +51,16 @@ function immichRequest(method, apiPath, headers, body) { } const server = http.createServer((req, res) => { + // --- Custom endpoint: report current injected-script version --- + if (req.url === '/api/custom/inject-version' && req.method === 'GET') { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store, no-cache, must-revalidate' + }); + res.end(JSON.stringify({ version: INJECT_VERSION })); + return; + } + // --- Custom endpoint: batch fetch exif (needs buffered body) --- if (req.url === '/api/custom/batch-exif' && req.method === 'POST') { let bodyChunks = []; @@ -116,6 +135,11 @@ const server = http.createServer((req, res) => { delete resHeaders['content-length']; delete resHeaders['content-encoding']; resHeaders['content-type'] = 'text/html; charset=utf-8'; + // Prevent browser + CDN from caching our modified HTML so the + // inline script version check stays fresh on every navigation. + resHeaders['cache-control'] = 'no-store, no-cache, must-revalidate'; + delete resHeaders['etag']; + delete resHeaders['last-modified']; res.writeHead(proxyRes.statusCode, resHeaders); res.end(html);