From 0cbebfef2ae780ebca91e557b98490d546c56b45 Mon Sep 17 00:00:00 2001 From: Nate Emmert Date: Thu, 15 Oct 2026 14:33:40 -0400 Subject: [PATCH] Cache-proof asset URLs and inline iOS touch icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reverse proxy in front of the app serves stale copies of static files for hours (live app.js and sw.js were old next to new HTML). iOS also showed a letter tile (N / S) because its separate icon download failed. - HTML pages are served through a versioner: every local script/stylesheet/icon/manifest gets a content-hash query (app.js?v=…), so any change is a URL no proxy or phone has cached - Service worker URL versioned too; SW offline lookups tolerate the query string; cache v15 - The 180px apple-touch-icon is embedded in each page as a data: URI, so the Home Screen icon no longer depends on a second network request Co-Authored-By: Claude Sonnet 5.5 --- app/public/sw.js | 6 ++--- app/server.js | 70 +++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/app/public/sw.js b/app/public/sw.js index c4ff9eb..a2f6b54 100644 --- a/app/public/sw.js +++ b/app/public/sw.js @@ -4,7 +4,7 @@ self.addEventListener('message', event => { if (event.data === 'skipWaiting') self.skipWaiting(); }); -const CACHE = 'weather-v14'; +const CACHE = 'weather-v15'; // App shell cached on install for offline resilience. const PRECACHE = [ @@ -101,14 +101,14 @@ self.addEventListener('fetch', event => { } return response; }) - .catch(() => caches.match(request).then(cached => cached || caches.match('/'))) + .catch(() => caches.match(request, { ignoreSearch: true }).then(cached => cached || caches.match('/', { ignoreSearch: true }))) ); return; } // Cache-first for static assets. event.respondWith( - caches.match(request).then(cached => { + caches.match(request, { ignoreSearch: true }).then(cached => { if (cached) return cached; return fetch(request).then(response => { if (response.ok && ( diff --git a/app/server.js b/app/server.js index 1adb4ca..77ff64f 100644 --- a/app/server.js +++ b/app/server.js @@ -619,6 +619,63 @@ function isValidCoord(value, min, max) { return Number.isFinite(n) && n >= min && n <= max; } +// ── Cache-proof asset URLs ─────────────────────────────────────────────────── +// A reverse proxy in front of the app was serving stale copies of static files (old app.js / sw.js) +// next to new HTML. HTML is always fetched fresh, so every local script, stylesheet, icon and manifest +// it references gets a content-hash version (app.js?v=ab12cd34): any change is a brand-new URL that no +// proxy or phone has cached. +const PUBLIC_DIR = path.join(__dirname, 'public'); +const assetHashes = new Map(); // absolute path -> { mtimeMs, hash } +function assetVersion(url) { + const rel = url.replace(/^\//, ''); + const abs = path.join(PUBLIC_DIR, rel); + if (!abs.startsWith(PUBLIC_DIR)) return null; + try { + const st = fs.statSync(abs); + const hit = assetHashes.get(abs); + if (hit && hit.mtimeMs === st.mtimeMs) return hit.hash; + const hash = crypto.createHash('sha1').update(fs.readFileSync(abs)).digest('hex').slice(0, 10); + assetHashes.set(abs, { mtimeMs: st.mtimeMs, hash }); + return hash; + } catch { return null; } +} +const inlineIcons = new Map(); +function inlineIcon(url) { + const abs = path.join(PUBLIC_DIR, url.replace(/^\//, '')); + if (!abs.startsWith(PUBLIC_DIR)) return null; + try { + const st = fs.statSync(abs); + const hit = inlineIcons.get(abs); + if (hit && hit.mtimeMs === st.mtimeMs) return hit.uri; + const uri = 'data:image/png;base64,' + fs.readFileSync(abs).toString('base64'); + inlineIcons.set(abs, { mtimeMs: st.mtimeMs, uri }); + return uri; + } catch { return null; } +} +function versionedHtml(file) { + let html = fs.readFileSync(path.join(PUBLIC_DIR, file), 'utf8'); + html = html.replace(/\b(src|href)="([^"#?]+\.(?:js|css|png|svg|webmanifest|json))"/g, (m, attr, url) => { + if (/^(?:[a-z]+:)?\/\//i.test(url)) return m; // external (https://, //cdn) + const v = assetVersion(url); + return v ? `${attr}="${url}?v=${v}"` : m; + }); + // iOS draws a letter tile if its separate icon download fails (proxy, network path, cached failure). + // Embed the 180px touch icon in the page itself so the image arrives with the HTML, no second request. + html = html.replace(//, (m, url) => { + const uri = inlineIcon(url); + return uri ? `` : m; + }); + // the service worker script is cached by the same proxy, so version its URL too + const swV = assetVersion('/sw.js'); + if (swV) html = html.replace(/register\('\/sw\.js'\)/g, `register('/sw.js?v=${swV}')`); + return html; +} +function sendHtml(res, file) { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + res.send(versionedHtml(file)); +} + // Keep shell assets fresh so SW/app versions do not drift. app.use('/', (req, res, next) => { if (req.path === '/' || req.path.endsWith('.html')) { @@ -634,6 +691,7 @@ app.use('/', (req, res, next) => { next(); }); +app.get(['/', '/index.html'], (_req, res) => sendHtml(res, 'index.html')); app.use(express.static(path.join(__dirname, 'public'))); // Prevent browser favicon requests from showing noisy 404s. @@ -1430,7 +1488,7 @@ app.use('/dashboard', express.urlencoded({ extended: false })); // Setup (first-time account creation) app.get('/dashboard/setup', (req, res) => { if (hasUsers()) return res.redirect('/dashboard'); - res.sendFile(path.join(__dirname, 'public', 'dashboard-setup.html')); + sendHtml(res, 'dashboard-setup.html'); }); // First-run setup creates the initial admin account @@ -1453,7 +1511,7 @@ app.post('/dashboard/setup', (req, res) => { app.get('/dashboard/login', (req, res) => { if (!hasUsers()) return res.redirect('/dashboard/setup'); if (currentUser(req)) return res.redirect('/dashboard'); - res.sendFile(path.join(__dirname, 'public', 'dashboard-login.html')); + sendHtml(res, 'dashboard-login.html'); }); app.post('/dashboard/login', (req, res) => { @@ -1481,7 +1539,7 @@ app.get('/dashboard/logout', (_req, res) => { // Password recovery: the daily token (printed in the server log) resets the primary admin account app.get('/dashboard/recover', (_req, res) => { if (!hasUsers()) return res.redirect('/dashboard/setup'); - res.sendFile(path.join(__dirname, 'public', 'dashboard-recover.html')); + sendHtml(res, 'dashboard-recover.html'); }); app.post('/dashboard/recover', (req, res) => { @@ -1504,16 +1562,16 @@ app.post('/dashboard/recover', (req, res) => { // Main dashboard app.get('/dashboard', requireDashboardAuth, (_req, res) => { - res.sendFile(path.join(__dirname, 'public', 'dashboard.html')); + sendHtml(res, 'dashboard.html'); }); // Settings page // Account page: everyone can change their own password; admins also manage users here app.get('/dashboard/account', requireDashboardAuth, (_req, res) => { - res.sendFile(path.join(__dirname, 'public', 'dashboard-account.html')); + sendHtml(res, 'dashboard-account.html'); }); app.get('/dashboard/settings', requireDashboardAuth, requireAdmin, (_req, res) => { - res.sendFile(path.join(__dirname, 'public', 'dashboard-settings.html')); + sendHtml(res, 'dashboard-settings.html'); }); // Downsample into ~`points` time buckets: averages for smooth series, true max/min for