628cc5722c
Root cause of the intermittent white screen: the shell HTML is no-cache (cf-cache-status: DYNAMIC), so every page-open does a synchronous round-trip to the residential origin before any pixel renders — and the SW's network-first navigation only fell back to the cached shell on REJECTION, never on slowness. A stalled fetch meant staring at white with a perfectly good shell in cache. The boot seatbelt couldn't see it either: it lives inside the HTML that hadn't arrived yet, so slow boots left no telemetry. - service-worker: race navigation fetch vs 2.5s grace timer. Network wins → fresh HTML as before; timer/5xx/failure → cached shell instantly, network response still refreshes the cache in the background. Safe due to the 14-day immutable-chunk grace window. Caps the white screen at ~2.5s for repeat visitors on any network. - app.html: beacon `boot-slow: Nms (html Nms) on 4g` when mount takes >4s — the "white screen, then it loaded" glitches finally leave a trace, with HTML-arrival timing to separate slow-origin from slow-JS. - admin: bot UAs (HeadlessChrome/bot/spider/crawl/…) excluded from the headline "Load errors today" count — throttled crawlers trip the 10s boot check routinely (the one recorded error was HeadlessChrome on X11, not a phone). Bots stay visible in the list, tagged + dimmed. Tests: telemetry test extended for bot flag + filtered counts. 223 pytest + 11 vitest green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
90 lines
3.6 KiB
JavaScript
90 lines
3.6 KiB
JavaScript
/// <reference types="@sveltejs/kit" />
|
|
// Calm, lightweight service worker. It does NOT bulk-precache on install — the
|
|
// browser already caches the year-immutable assets on its own, and a cold
|
|
// precache storm right after a deploy hammers the (residential) origin and slows
|
|
// first loads. Instead: cache the shell for an offline fallback, and cache other
|
|
// assets lazily as they're actually used. Live data (API + server-rendered pages)
|
|
// is always fetched fresh.
|
|
import { version } from '$service-worker';
|
|
|
|
const CACHE = `upbeat-${version}`;
|
|
// How long a navigation may wait on the network before the cached shell is
|
|
// served instead. Long enough for a healthy fetch, short enough that a stalled
|
|
// cellular/origin hop never reads as a broken site.
|
|
const NAV_TIMEOUT_MS = 2500;
|
|
|
|
// Paths the FastAPI server owns — the SW must NOT intercept or cache these.
|
|
function isServerPath(p) {
|
|
if (p.startsWith('/api/') || p.startsWith('/a/') || p.startsWith('/docs')) return true;
|
|
return p === '/openapi.json' || p === '/healthz' || p === '/today' || p === '/sitemap.xml';
|
|
}
|
|
|
|
self.addEventListener('install', (event) => {
|
|
// Best-effort: grab the app shell as an offline fallback. No bulk precache.
|
|
event.waitUntil(
|
|
caches.open(CACHE).then((c) => c.add('/').catch(() => {})).then(() => self.skipWaiting())
|
|
);
|
|
});
|
|
|
|
self.addEventListener('activate', (event) => {
|
|
event.waitUntil(
|
|
caches
|
|
.keys()
|
|
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
|
.then(() => self.clients.claim())
|
|
);
|
|
});
|
|
|
|
self.addEventListener('fetch', (event) => {
|
|
const { request } = 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
|
|
|
|
// 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
|
|
// against a short grace timer: network wins → freshest HTML as usual; timer
|
|
// wins (or 5xx/failure) → serve the cached shell instantly while the network
|
|
// response still lands in the cache for next time. A slightly stale shell is
|
|
// safe: deploys keep old immutable chunks for a 14-day grace window.
|
|
if (request.mode === 'navigate') {
|
|
event.respondWith(
|
|
(async () => {
|
|
const cache = await caches.open(CACHE);
|
|
const cached = await cache.match('/');
|
|
const network = fetch(request)
|
|
.then((res) => {
|
|
if (res && res.ok && (res.headers.get('content-type') || '').includes('text/html')) {
|
|
cache.put('/', res.clone()).catch(() => {});
|
|
}
|
|
return res;
|
|
})
|
|
.catch(() => null);
|
|
if (!cached) return (await network) || Response.error(); // first visit: network only
|
|
const winner = await Promise.race([
|
|
network,
|
|
new Promise((resolve) => setTimeout(() => resolve('slow'), NAV_TIMEOUT_MS)),
|
|
]);
|
|
return winner && winner !== 'slow' && winner.ok ? winner : cached;
|
|
})()
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Static assets: serve from cache if present, else fetch and cache for next time
|
|
// (cache-as-you-go — no install storm). Only cache successful same-origin GETs.
|
|
event.respondWith(
|
|
caches.match(request).then((cached) => {
|
|
if (cached) return cached;
|
|
return fetch(request).then((res) => {
|
|
if (res && res.ok && res.type === 'basic') {
|
|
const copy = res.clone();
|
|
caches.open(CACHE).then((c) => c.put(request, copy)).catch(() => {});
|
|
}
|
|
return res;
|
|
});
|
|
})
|
|
);
|
|
});
|