feat: /__reset recovery page + no-store on 5xx responses

/__reset returns a self-contained page that unregisters every service
worker, clears every cache, drops the heatmap API key, and reloads.
Use when a PWA is stuck on a bad cached chunk or SW state — visiting
photos.jeffemmett.com/__reset (even inside the broken PWA) fixes it.

Also mark any upstream 5xx response as no-store so a transient error
during a container restart can't get pinned into a browser or CDN
cache and brick the PWA long-term.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-17 13:05:43 -04:00
parent af3468e0d5
commit 76f8816b82
1 changed files with 68 additions and 1 deletions

View File

@ -70,7 +70,65 @@ function immichRequest(method, apiPath, headers, body) {
});
}
// Standalone recovery page: unregister service workers, clear all caches,
// clear storage, and reload. Use when a PWA is stuck on a broken cached
// chunk or service worker state.
const RESET_HTML = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Resetting</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: system-ui, sans-serif; background:#111; color:#eee;
display:flex; align-items:center; justify-content:center;
height:100vh; margin:0; flex-direction:column; gap:16px; padding:20px; }
.spinner { width:40px; height:40px; border:4px solid #333;
border-top-color:#e94560; border-radius:50%;
animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.status { font-size:15px; text-align:center; max-width:320px; }
a { color:#e94560; }
</style>
</head><body>
<div class="spinner"></div>
<div class="status" id="status">Clearing caches</div>
<script>
(async function(){
const s = document.getElementById('status');
const log = (m) => { s.textContent = m; };
try {
if ('serviceWorker' in navigator) {
log('Unregistering service workers…');
const regs = await navigator.serviceWorker.getRegistrations();
await Promise.all(regs.map(r => r.unregister().catch(() => null)));
}
if (window.caches) {
log('Clearing caches…');
const keys = await caches.keys();
await Promise.all(keys.map(k => caches.delete(k).catch(() => null)));
}
try { localStorage.removeItem('ls-heatmap-api-key'); } catch {}
} catch (e) {
log('Error: ' + e.message + ' — reloading anyway.');
}
log('Done. Reloading…');
setTimeout(() => {
location.replace('/?_ts=' + Date.now());
}, 500);
})();
</script>
</body></html>`;
const server = http.createServer((req, res) => {
// --- Recovery page: /__reset ---
if ((req.url === '/__reset' || req.url.startsWith('/__reset?')) && req.method === 'GET') {
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
'Service-Worker-Allowed': '/'
});
res.end(RESET_HTML);
return;
}
// --- Custom endpoint: report current injected-script version ---
if (req.url === '/api/custom/inject-version' && req.method === 'GET') {
res.writeHead(200, {
@ -184,7 +242,16 @@ const server = http.createServer((req, res) => {
});
} else {
// Stream non-HTML responses directly (videos, images, API JSON)
res.writeHead(proxyRes.statusCode, proxyRes.headers);
const streamHeaders = { ...proxyRes.headers };
// Don't let browsers or CDNs cache error responses — a 5xx
// that sneaks into a long-lived cache entry can brick the PWA.
if (proxyRes.statusCode >= 500) {
streamHeaders['cache-control'] = 'no-store, no-cache, must-revalidate';
delete streamHeaders['expires'];
delete streamHeaders['etag'];
delete streamHeaders['last-modified'];
}
res.writeHead(proxyRes.statusCode, streamHeaders);
proxyRes.pipe(res);
}
});