Cache-proof asset URLs and inline iOS touch icon
Publish Container / publish (push) Successful in 2m42s

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 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 5.5 committed 2026-10-15 14:33:40 -04:00
1 parent f21c91527c
commit 0cbebfef2a
2 files changed
+67 -9

No files matched your search

+3 -3
View File
@@ -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 && (
+64 -6
View File
@@ -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(/<link rel="apple-touch-icon" sizes="180x180" href="(\/icons\/v2\/[^"?]+\.png)(?:\?v=[0-9a-f]+)?">/, (m, url) => {
const uri = inlineIcon(url);
return uri ? `<link rel="apple-touch-icon" sizes="180x180" href="${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