SW: mutable no-cache files bypass the SW cache (Codex audit) + beacon build id
Codex's finding: cache-as-you-go would pin files Caddy deliberately serves no-cache (version.json, manifest, word lists, icons) in the SW cache until the next SW version — silently defeating the revalidate policy for controlled clients. version.json is the critical one (it's how the app detects a fresh deploy); stale word lists could drift from the server's validated answer pool. New isMutablePath() exclusion: the SW steps aside and the browser HTTP cache revalidates these per their headers. Telemetry polish (also Codex): the boot beacon now fills the app_version column with the entry chunk's hashed filename scraped from the shell's own modulepreload link (no extra fetch) — deploy-correlated load errors become obvious. Admin list returns + shows it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -42,8 +42,13 @@
|
||||
function report(reason) {
|
||||
if (sent) return; sent = true; // one beacon per page
|
||||
try {
|
||||
// The entry chunk's hashed filename identifies the build this page
|
||||
// booted from — lets deploy-related errors correlate to a version.
|
||||
var ver = '';
|
||||
var pre = document.querySelector('link[rel="modulepreload"]');
|
||||
if (pre) ver = (pre.getAttribute('href') || '').split('/').pop().slice(0, 60);
|
||||
var b = new Blob([JSON.stringify({ reason: String(reason || 'unknown').slice(0, 500),
|
||||
path: location.pathname })], { type: 'application/json' });
|
||||
path: location.pathname, version: ver })], { type: 'application/json' });
|
||||
navigator.sendBeacon && navigator.sendBeacon('/api/client-error', b);
|
||||
} catch (e) { /* best-effort telemetry */ }
|
||||
}
|
||||
|
||||
@@ -462,7 +462,7 @@
|
||||
<span class="ce-when">{fdate(e.created_at)}</span>
|
||||
<span class="ce-reason">{e.reason || '—'}{#if e.bot}<span class="ce-bot">bot</span>{/if}</span>
|
||||
<span class="ce-path">{e.path || '/'}</span>
|
||||
<span class="ce-ua">{e.user_agent}</span>
|
||||
<span class="ce-ua">{e.user_agent}{#if e.app_version} · build {e.app_version}{/if}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -19,6 +19,25 @@ function isServerPath(p) {
|
||||
return p === '/openapi.json' || p === '/healthz' || p === '/today' || p === '/sitemap.xml';
|
||||
}
|
||||
|
||||
// Mutable files Caddy serves with `no-cache` — the browser's HTTP cache
|
||||
// revalidates these correctly, but SW cache-as-you-go would pin them until the
|
||||
// next SW version and silently defeat that policy. version.json is the big one
|
||||
// (it's how the app detects a new deploy); stale word lists could drift from
|
||||
// the server's validated answer pool. Let the network/browser cache own them.
|
||||
function isMutablePath(p) {
|
||||
return (
|
||||
p === '/service-worker.js' ||
|
||||
p === '/_app/version.json' ||
|
||||
p === '/manifest.webmanifest' ||
|
||||
p === '/words-5.json' ||
|
||||
p === '/words-6.json' ||
|
||||
p === '/favicon.svg' ||
|
||||
p === '/logo.svg' ||
|
||||
p === '/logo-email.png' ||
|
||||
p.startsWith('/icon-')
|
||||
);
|
||||
}
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
// Best-effort: grab the app shell as an offline fallback. No bulk precache.
|
||||
event.waitUntil(
|
||||
@@ -40,7 +59,7 @@ self.addEventListener('fetch', (event) => {
|
||||
if (request.method !== 'GET') return;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== location.origin) return;
|
||||
if (isServerPath(url.pathname)) return; // let the network/server handle these
|
||||
if (isServerPath(url.pathname) || isMutablePath(url.pathname)) return; // network/browser cache owns these
|
||||
|
||||
// Navigations: network-first, but a SLOW network must not mean a white screen —
|
||||
// "slow" and "failed" both fall back to the cached shell. We race the fetch
|
||||
|
||||
+1
-1
@@ -959,7 +959,7 @@ def create_app() -> FastAPI:
|
||||
with get_conn() as conn:
|
||||
_require_admin(conn, request)
|
||||
rows = conn.execute(
|
||||
"SELECT reason, path, user_agent, created_at FROM client_errors ORDER BY id DESC LIMIT 20"
|
||||
"SELECT reason, path, user_agent, app_version, created_at FROM client_errors ORDER BY id DESC LIMIT 20"
|
||||
).fetchall()
|
||||
# Bots stay visible in the list (tagged) but are excluded from the
|
||||
# headline counts — see queries.admin_stats.
|
||||
|
||||
+3
-1
@@ -412,12 +412,14 @@ def test_word_pool_admin(tmp_path, monkeypatch):
|
||||
def test_client_error_telemetry(tmp_path, monkeypatch):
|
||||
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
||||
anon = TestClient(app)
|
||||
assert anon.post("/api/client-error", json={"reason": "boot-timeout", "path": "/play"}).json()["ok"] is True
|
||||
assert anon.post("/api/client-error",
|
||||
json={"reason": "boot-timeout", "path": "/play", "version": "start.Bzfu1yPF.js"}).json()["ok"] is True
|
||||
assert anon.get("/api/admin/client-errors").status_code == 401 # gated
|
||||
tc = _signin(app, api, "boss@x.com")
|
||||
rows = tc.get("/api/admin/client-errors").json()
|
||||
assert len(rows) == 1 and rows[0]["reason"] == "boot-timeout" and rows[0]["path"] == "/play"
|
||||
assert rows[0]["user_agent"] # captured from the request header
|
||||
assert rows[0]["app_version"] == "start.Bzfu1yPF.js" # build correlation for deploy-related errors
|
||||
assert rows[0]["bot"] is False
|
||||
assert tc.get("/api/admin/stats").json()["client_errors"]["today"] == 1
|
||||
# A throttled crawler tripping the beacon must NOT inflate the headline count,
|
||||
|
||||
Reference in New Issue
Block a user