1/** 2 * schools-closings.com â Service Worker 3 * ====================================================================== 4 * Strategy summary (see cache config below for the reasoning): 5 * 6 * 1. Ad / monetization / third-party script domains (AdSense, GA, 7 * Gravitec push SDK, PayPal, FeedBlitz) are NEVER intercepted â 8 * the browser handles them exactly as if there were no service 9 * worker at all. This keeps AdSense serving policy-compliant and 10 * avoids ever serving a stale ad script. 11 * 12 * 2. Anything under /api/ (live closing status, listings, forecasts, 13 * crowd reports, etc.) is NETWORK-FIRST with a short timeout and 14 * NO long-term cache â during an active storm, a parent must never 15 * see yesterday's cached "open" status presented as if it were 16 * current. A short-lived fallback cache exists purely so the page 17 * isn't blank if the network drops mid-session â every fallback 18 * response is timestamped and explicitly flagged as stale via 19 * response headers (`x-sc-stale-fallback`, `x-sc-cached-at`, 20 * `x-sc-age-ms`, `x-sc-stale-expired`) so the page can never 21 * mistake a cached fallback for a live result. See the 22 * "Runtime cache headers contract" section in README.md. 23 * 24 * 3. District/city HTML pages (â¦-school-closings-today/) are also 25 * NETWORK-FIRST â these embed live status widgets, so the same 26 * "never show stale during a storm" rule applies to the page shell 27 * too, not just the JSON. 28 * 29 * 4. Static assets (CSS/JS/fonts/icons/manifest) are CACHE-FIRST with 30 * a versioned cache name â safe to cache aggressively since they 31 * rarely change, and bumping CACHE_VERSION on deploy guarantees 32 * old files are purged instead of lingering. On top of that, each 33 * cached entry is timestamped and treated as stale after 34 * STATIC_MAX_AGE_MS (24h) â a stale entry is still served instantly, 35 * but is silently re-fetched in the background so the next load 36 * already has the current file. This is a SAFETY NET for a 37 * forgotten version bump, not a replacement for it â see the 38 * worker.js/school-status-app.js note in README.md for why an 39 * API-shape change still needs the instant, deliberate bump. 40 * 41 * 5. A minimal offline fallback page is precached so a visitor who 42 * opens the PWA with no connection still sees something useful 43 * instead of the browser's default offline error. 44 * ====================================================================== 45 */ 46 47// Bump this on every deploy that changes cached static assets so old 48// caches are cleared automatically (see activate handler below). 49const CACHE_VERSION = '2026-09-23.0001'; 50const STATIC_CACHE = `sc-static-${CACHE_VERSION}`; 51const RUNTIME_CACHE = `sc-runtime-${CACHE_VERSION}`; 52const OFFLINE_URL = '/pwa/offline.html'; 53 54// Precached app-shell assets â keep this list small and static-only. 55const PRECACHE_ASSETS = [ 56 '/pwa/manifest.json', 57 '/pwa/icons/icon-192.png', 58 '/pwa/icons/icon-512.png', 59 OFFLINE_URL, 60]; 61 62// Domains that must NEVER be touched by this service worker â ad 63// serving, analytics, push SDKs, and payment processing. Requests to 64// these are left completely alone (native network request, native 65// browser cache headers, no SW involvement at all). 66const NEVER_INTERCEPT_HOSTS = [ 67 'googlesyndication.com', 68 'doubleclick.net', 69 'googleadservices.com', 70 'google-analytics.com', 71 'googletagmanager.com', 72 'adtrafficquality.google', 73 'onesignal.gravitec.net', 74 'gravitec.net', 75 'paypal.com', 76 'paypalobjects.com', 77 'app.feedblitz.com', 78]; 79 80// Network-first, short-timeout: never let a slow/dead network make the 81// page hang â fall back to cache (if any) after NETWORK_TIMEOUT_MS. 82const NETWORK_TIMEOUT_MS = 4000; 83 84self.addEventListener('install', (event) => { 85 event.waitUntil( 86 caches.open(STATIC_CACHE) 87 .then((cache) => cache.addAll(PRECACHE_ASSETS)) 88 .then(() => self.skipWaiting()) 89 ); 90}); 91 92// Surface otherwise-silent SW failures to the console (and, since these 93// events aren't visible in normal page devtools without opening the SW 94// inspector, this is what you'd grep for in a bug report / remote log 95// if you wire one up later). 96self.addEventListener('error', (event) => { 97 console.error('[pwa][sw-error]', event.message, event.filename, event.lineno); 98}); 99self.addEventListener('unhandledrejection', (event) => { 100 console.error('[pwa][sw-error] unhandled rejection:', event.reason); 101}); 102 103self.addEventListener('activate', (event) => { 104 event.waitUntil( 105 caches.keys() 106 .then((keys) => Promise.all( 107 keys 108 .filter((key) => key !== STATIC_CACHE && key !== RUNTIME_CACHE) 109 .map((key) => caches.delete(key)) 110 )) 111 .then(() => self.clients.claim()) 112 ); 113}); 114 115function isNeverIntercepted(url) {
116 return NEVER_INTERCEPT_HOSTS.some((host) => url.hostname.endsWith(host)); 117} 118 119function isLiveDataRequest(url) { 120 // Anything the Worker serves under /api/ (or the legacy /status 121 // alias) plus any district "â¦-school-closings-today" page â all 122 // time-sensitive, must always try the network first. 123 // Note the leading hyphen in the pattern: real district pages are 124 // named like "chandler-az-school-closings-today/" â requiring the 125 // hyphen makes the intent explicit and avoids an unrelated future 126 // route that merely ends in "school-closings-today" matching by 127 // accident. 128 return url.pathname.startsWith('/api/') 129 || url.pathname === '/status' 130 || /-school-closings-today\/?$/.test(url.pathname) 131 || url.pathname.includes('snowpredictor') 132 || url.pathname.includes('school-alerts-state-checker'); 133} 134 135function isStaticAsset(request, url) { 136 return ['style', 'script', 'font', 'image'].includes(request.destination) 137 || /\.(css|js|woff2?|ttf|png|jpe?g|svg|webp|ico)$/i.test(url.pathname) 138 || url.pathname.startsWith('/pwa/icons/') 139 || url.pathname === '/pwa/manifest.json'; 140} 141 142// Runtime cache is a FALLBACK ONLY â used purely so a page isn't blank 143// if the network drops mid-session. It is deliberately NOT the same 144// thing as "cached and therefore fine to treat as current." Every 145// entry is timestamped, and every response served FROM this cache 146// (i.e. the network attempt failed or timed out) is tagged with 147// headers identifying it as a fallback and stating how old it is â 148// see networkFirst() below. This is what closes the gap where a 149// Monday "all open" response could otherwise still be served on 150// Tuesday, unlabeled, as if it were current. 151// 152// These max-ages don't cause data to be deleted or withheld â stale 153// data is still better than no data during an outage. They exist so 154// the response can be flagged "expired" (vs. merely "cached a few 155// minutes ago") via the `x-sc-stale-expired` header, letting the page 156// show a stronger warning for badly-outdated fallbacks. The actual 157// display copy ("You're offline â showing the last available status 158// from 8:42 AM") lives in school-status-app.js, which isn't part of 159// this package â see the "Runtime cache headers contract" section in 160// README.md for the exact header names/format it should read. 161const RUNTIME_MAX_AGE_BY_CATEGORY = { 162 api: 45 * 60 * 1000, // /api/*, /status â 45 min 163 districtPage: 45 * 60 * 1000, // *-school-closings-today/ â 45 min 164 predictor: 2 * 60 * 60 * 1000, // snowpredictor, state checker â 2 hr 165}; 166 167function getRuntimeCategory(url) { 168 if (url.pathname.includes('snowpredictor') || url.pathname.includes('school-alerts-state-checker')) { 169 return 'predictor'; 170 } 171 if (url.pathname.startsWith('/api/') || url.pathname === '/status') { 172 return 'api'; 173 } 174 return 'districtPage'; 175} 176 177async function putRuntimeWithTimestamp(cache, request, response) { 178 const headers = new Headers(response.headers); 179 headers.set('x-sc-cached-at', String(Date.now())); 180 const stamped = new Response(await response.blob(), { 181 status: response.status, 182 statusText: response.statusText, 183 headers, 184 }); 185 await cache.put(request, stamped); 186} 187 188// --- Last-known-status tracking (feeds offline.html) ----------------- 189// Whenever a district page or /api/status-style request SUCCEEDS, we 190// record a small "last known" snapshot (district name, timestamp, and 191// a best-effort status label) so that if the visitor later opens an 192// UNCACHED page with no connection at all, offline.html can still show 193// something useful ("Last checked 8:52 AM â Chandler USD: OPEN") rather 194// than a bare "you're offline" message. This is a Cache-API-backed 195// key/value store, not a real network endpoint â see LAST_KNOWN_ENDPOINT 196// below, which is intercepted entirely by this service worker and never 197// touches the actual server. 198const LAST_KNOWN_ENDPOINT = '/pwa/last-known-status.json'; 199 200function titleCase(word) { 201 return word.charAt(0).toUpperCase() + word.slice(1); 202} 203 204// Turns a URL slug like "chandler-az" into a display name like 205// "Chandler, AZ". Heuristic: if the last hyphen-separated token is 206// exactly 2 letters, treat it as a US state code; everything else is 207// title-cased as the city/district name. Good enough for the existing 208// "<city>-<state>-school-closings-today" fragment naming convention â 209// revisit if district slugs ever stop following that pattern. 210function formatDistrictName(slug) {
211 const parts = slug.split('-').filter(Boolean); 212 if (parts.length >= 2 && parts[parts.length - 1].length === 2) { 213 const state = parts.pop().toUpperCase(); 214 return `${parts.map(titleCase).join(' ')}, ${state}`; 215 } 216 return parts.map(titleCase).join(' '); 217} 218 219function extractCitySlug(url) { 220 // /api/status?city=chandler-az style requests 221 const fromQuery = url.searchParams.get('city'); 222 if (fromQuery) return fromQuery; 223 // /chandler-az-school-closings-today/ style page navigations 224 const m = /^\/([a-z0-9-]+)-school-closings-today\/?$/i.exec(url.pathname); 225 return m ? m[1] : null; 226} 227 228async function getLastKnownStore() { 229 const cache = await caches.open(RUNTIME_CACHE); 230 const cached = await cache.match(LAST_KNOWN_ENDPOINT); 231 if (!cached) return { entries: {}, mostRecentSlug: null }; 232 try { 233 return await cached.json(); 234 } catch (err) { 235 return { entries: {}, mostRecentSlug: null }; 236 } 237} 238 239async function saveLastKnownStore(store) { 240 const cache = await caches.open(RUNTIME_CACHE); 241 await cache.put(LAST_KNOWN_ENDPOINT, new Response(JSON.stringify(store), { 242 headers: { 'content-type': 'application/json' }, 243 })); 244} 245 246// Confirmed against worker.js's handleCityStatus(): a city-status 247// response has NO single top-level "status" field â status lives 248// per-board in `boards[].actualStatus` (or the legacy `.code`, kept in 249// sync with it), with values 'open' | 'closed' | 'delay' | 'delayed' | 250// 'no-report' (worker.js uses 'delay' in the score-based path and 251// 'delayed' in the state-summary path â both are handled below). 252// `city`/`state` on the response are the authoritative display name 253// straight from cityConfig, and are preferred over the slug-guessing 254// heuristic below when present. 255const STATUS_RANK = { closed: 3, delay: 2, delayed: 2, open: 1, 'no-report': 1 }; 256 257// Reduces a city's boards[] to one headline label for the offline card. 258// A city can have multiple boards (multiple districts); the worst status 259// wins, since "some schools in Chandler are closed" is the version of 260// the truth a parent needs to see, not "the first board we checked." 261function deriveOverallStatus(data) { 262 if (!Array.isArray(data.boards) || !data.boards.length) return null; 263 let best = null; 264 for (const b of data.boards) { 265 const status = b.actualStatus || b.code; 266 const rank = STATUS_RANK[status] || 0; 267 if (!best || rank > best.rank) best = { status, rank, label: b.label }; 268 } 269 if (!best) return null; 270 if (best.status === 'closed') return 'CLOSED'; 271 if (best.status === 'delay' || best.status === 'delayed') return 'DELAYED'; 272 // 'no-report' â nothing confirmed. Don't claim OPEN when the real 273 // response explicitly distinguishes "confirmed open" from "nothing 274 // reported yet" (see worker.js's actualStatus/predictedStatus split). 275 if (best.status === 'no-report') return best.label || 'No closures reported'; 276 return 'OPEN'; 277} 278 279async function recordLastKnownStatus(url, response) { 280 const category = getRuntimeCategory(url); 281 if (category !== 'api' && category !== 'districtPage') return; 282 283 const citySlug = extractCitySlug(url); 284 if (!citySlug) return; // can't attribute this response to a specific district 285 286 const entry = { 287 district: formatDistrictName(citySlug), 288 lastCheckedAt: Date.now(), 289 }; 290 291 if (category === 'api') { 292 try { 293 const data = await response.clone().json(); 294 // Prefer the response's own city/state â it's authoritative 295 // (straight from cityConfig), the slug heuristic is only a 296 // fallback for cases where we don't have a parsed JSON body 297 // (e.g. a district PAGE navigation, not the /api/ call itself). 298 if (data.city) entry.district = data.state ? `${data.city}, ${data.state}` : data.city; 299 entry.statusLabel = deriveOverallStatus(data); 300 } catch (err) { 301 // Not JSON, or body already consumed â still record the visit 302 // itself, just without a status label. 303 } 304 } 305 306 const store = await getLastKnownStore(); 307 store.entries[citySlug] = { ...(store.entries[citySlug] || {}), ...entry }; 308 store.mostRecentSlug = citySlug;
309 await saveLastKnownStore(store).catch((err) => { 310 console.error('[pwa][sw-error] failed to save last-known-status:', err); 311 }); 312} 313 314async function networkFirst(request) { 315 const url = new URL(request.url); 316 try { 317 const controller = new AbortController(); 318 const timer = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS); 319 const response = await fetch(request, { signal: controller.signal }); 320 clearTimeout(timer); 321 322 // Only cache successful GETs as a short-lived offline fallback â 323 // never treat this as a source of "fresh" data on next load. 324 if (response.ok && request.method === 'GET') { 325 const cache = await caches.open(RUNTIME_CACHE); 326 putRuntimeWithTimestamp(cache, request, response.clone()); 327 recordLastKnownStatus(url, response.clone()).catch((err) => { 328 console.error('[pwa][sw-error] recordLastKnownStatus failed:', err); 329 }); 330 } 331 return response; 332 } catch (err) { 333 const cached = await caches.match(request); 334 if (cached) { 335 const cachedAt = Number(cached.headers.get('x-sc-cached-at') || 0); 336 const ageMs = cachedAt ? Date.now() - cachedAt : null; 337 const maxAge = RUNTIME_MAX_AGE_BY_CATEGORY[getRuntimeCategory(url)]; 338 const headers = new Headers(cached.headers); 339 // Always flag this as a stale fallback, regardless of age â the 340 // whole point is that "cached" should never look the same as 341 // "current" to the page. ageMs === null means we somehow have an 342 // untimestamped legacy entry; treat that as expired too. 343 headers.set('x-sc-stale-fallback', '1'); 344 headers.set('x-sc-cached-at', String(cachedAt || 0)); 345 headers.set('x-sc-age-ms', String(ageMs ?? '')); 346 headers.set('x-sc-stale-expired', String(ageMs === null || ageMs > maxAge)); 347 const flagged = new Response(await cached.clone().blob(), { 348 status: cached.status, 349 statusText: cached.statusText, 350 headers, 351 }); 352 return flagged; 353 } 354 if (request.mode === 'navigate') { 355 const offline = await caches.match(OFFLINE_URL); 356 if (offline) return offline; 357 } 358 throw err; 359 } 360} 361 362// Safety net on top of CACHE_VERSION bumping, not a replacement for it. 363// Bumping CACHE_VERSION on deploy is still the mechanism that clears a 364// cache INSTANTLY when you know something changed (e.g. an API-shape 365// change â see the worker.js/school-status-app.js note in the README). 366// This TTL just protects against the case where a version bump gets 367// forgotten: after MAX_AGE_MS, a cached static asset is served 368// immediately (so the page never waits on the network) but is also 369// silently re-fetched in the background, so the *next* load already 370// has the current file â "stale-while-revalidate." 371const STATIC_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours â generic static assets (icons, manifest, etc.) 372 373// Assets whose correctness is coupled to the /api/ response shape get a 374// much shorter self-heal window than generic static assets â if the 375// Worker's response shape changes and CACHE_VERSION isn't bumped, this 376// bounds how long a stale copy of the file can keep parsing live data 377// incorrectly, instead of riding out the full 24h default. 378const SHORT_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour 379const SHORT_MAX_AGE_PATTERNS = [/school-status-app\.js$/]; 380 381function getMaxAgeMs(url) { 382 return SHORT_MAX_AGE_PATTERNS.some((re) => re.test(url.pathname)) 383 ? SHORT_MAX_AGE_MS 384 : STATIC_MAX_AGE_MS; 385} 386 387async function cacheFirst(request) { 388 const url = new URL(request.url); 389 const cache = await caches.open(STATIC_CACHE); 390 const cached = await cache.match(request); 391 392 if (cached) { 393 const cachedAt = Number(cached.headers.get('sw-cached-at') || 0); 394 const isStale = !cachedAt || (Date.now() - cachedAt) > getMaxAgeMs(url); 395 396 if (isStale) { 397 // Don't block the response on this â revalidate in the background 398 // so THIS load is instant and the NEXT load gets the fresh file. 399 revalidateInBackground(request, cache); 400 } 401 return cached; 402 } 403 404 try { 405 const response = await fetch(request); 406 if (response.ok) await putWithTimestamp(cache, request, response.clone()); 407 return response; 408 } catch (err) { 409 // Static asset unavailable offline and not precached â nothing 410 // sensible to fall back to; let it fail. 411 throw err; 412 } 413} 414 415async function putWithTimestamp(cache, request, response) { 416 // Stamp our own header so we can tell how old a cached entry is â 417 // Cache API doesn't expose insertion time natively. 418 const headers = new Headers(response.headers);
419 headers.set('sw-cached-at', String(Date.now())); 420 const stamped = new Response(await response.blob(), { 421 status: response.status, 422 statusText: response.statusText, 423 headers, 424 }); 425 await cache.put(request, stamped); 426} 427 428function revalidateInBackground(request, cache) { 429 fetch(request) 430 .then((response) => { 431 if (response.ok) return putWithTimestamp(cache, request, response); 432 }) 433 .catch(() => { 434 // Network unavailable â keep serving the stale cached copy until 435 // the next successful revalidation attempt. 436 }); 437} 438 439// Immediate purge path, independent of CACHE_VERSION and independent of 440// the STATIC_MAX_AGE_MS/SHORT_MAX_AGE_MS timers above. register-sw.js 441// fetches /pwa/version.json (no-store) on every load and compares it to 442// the value it saw last time; on a mismatch it posts this message so a 443// forgotten CACHE_VERSION bump self-heals on the NEXT page load instead 444// of waiting out the TTL. 445self.addEventListener('message', (event) => { 446 if (!event.data || event.data.type !== 'PURGE_STATIC') return; 447 event.waitUntil( 448 caches.delete(STATIC_CACHE).then(() => { 449 // Recreate empty so subsequent cacheFirst() calls have somewhere 450 // to write; the precache assets will simply be re-fetched on 451 // next request rather than being blocked on here. 452 return caches.open(STATIC_CACHE); 453 }).then(() => { 454 // Let the page know the purge is done in case it wants to reload. 455 event.source && event.source.postMessage({ type: 'PURGE_STATIC_DONE' }); 456 }).catch((err) => { 457 console.error('[pwa][sw-error] PURGE_STATIC failed:', err); 458 }) 459 ); 460}); 461 462self.addEventListener('fetch', (event) => { 463 const { request } = event; 464 if (request.method !== 'GET') return; // never intercept POST (crowd reports, listings, etc.) 465 466 const url = new URL(request.url); 467 468 // 0. Virtual endpoint â served entirely from this SW's own store, never 469 // forwarded to the real server (no such file exists there). Read by 470 // offline.html's inline script to show the last known district status. 471 if (url.pathname === LAST_KNOWN_ENDPOINT) { 472 event.respondWith( 473 getLastKnownStore().then((store) => new Response(JSON.stringify(store), { 474 headers: { 'content-type': 'application/json' }, 475 })) 476 ); 477 return; 478 } 479 480 // 1. Ads / analytics / push / payments â hands off entirely. 481 if (isNeverIntercepted(url)) return; 482 483 // Only handle same-origin + known safe cross-origin static (fonts CDN etc.) 484 // beyond that, don't intercept â let the browser do its normal thing. 485 if (url.origin !== self.location.origin && !isStaticAsset(request, url)) { 486 return; 487 } 488 489 // 2. Live/time-sensitive data and district pages â network-first. 490 if (isLiveDataRequest(url) || request.mode === 'navigate') { 491 event.respondWith(networkFirst(request)); 492 return; 493 } 494 495 // 3. Static assets â cache-first, versioned cache. 496 if (isStaticAsset(request, url)) { 497 event.respondWith(cacheFirst(request)); 498 return; 499 } 500 501 // 4. Anything else â just pass through untouched. 502});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.