PageSourceSearch

https://www.schools-closings.com/pwa/sw.js

js schools-closings.com collected 2026-10-08 23:07:17 UTC 21,105 bytes, 502 lines download raw bytes

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.