Cache-proof asset URLs and inline iOS touch icon
Publish Container / publish (push) Successful in 2m42s
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:
1 parent
f21c91527c
commit
0cbebfef2a
2 files changed
+67
-9
No files matched your search
+3
-3
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user