1/* eslint-disable no-restricted-globals */ 2// 3// ××× ××§ · × ×××× â Service Worker. 4// 5// Two jobs, and deliberately nothing else: 6// 1. Keep the app openable with no network: a real offline screen instead of 7// the browser's dinosaur, plus the immutable build assets it needs. 8// 2. Finish an attendance sync after the page is gone (Background Sync). 9// 10// CACHING IS AN ALLOWLIST, NOT A DEFAULT. 11// Every page in this app is `force-dynamic`, sits behind `middleware.ts`, and 12// renders student PII â names, phone numbers, grades. Caching an HTML response 13// would leave that PII in the Cache Storage of a shared phone, and would serve 14// it to whoever opens the app next, signed in or not. So: navigations are 15// network-first and their responses are NEVER stored, `/api/*` never touches 16// the cache at all, and the only things written to disk are the hashed build 17// assets under /_next/static, the brand images and the manifest â none of 18// which vary by user. 19// 20// The constants below mirror src/lib/offline/sync-policy.ts by hand. There is 21// no bundler between this file and the browser, so the two cannot import each 22// other; if you rename a store there, rename it here. 23 24const VERSION = "znk-sw-v4"; 25const SHELL_CACHE = `${VERSION}-shell`; 26const ASSET_CACHE = `${VERSION}-assets`; 27 28const OFFLINE_URL = "/offline"; 29 30const DB_NAME = "znk-offline"; 31const DB_VERSION = 1; 32const QUEUE_STORE = "attendance_queue"; 33const OUTCOME_STORE = "attendance_outcomes"; 34const SYNC_TAG = "znk-attendance-sync"; 35const SYNC_DONE_MESSAGE = "znk-attendance-sync-done"; 36const MAX_ATTEMPTS = 8; 37 38// `next dev` serves unhashed chunk names that change on every rebuild, so 39// cache-first would pin stale JavaScript to the page. Offline behaviour still 40// works locally; only the asset caching is skipped. 41const IS_DEV = 42 self.location.hostname === "localhost" || self.location.hostname === "127.0.0.1"; 43 44// ------------------------------------------------------------- lifecycle 45 46self.addEventListener("install", (event) => { 47 event.waitUntil( 48 (async () => { 49 // Best effort, and the `try` is the whole point. cache.add() rejects on 50 // any non-2xx response and on a dead network, a rejection inside 51 // waitUntil fails the install, and a failed install leaves the OLD 52 // worker in service with no retry and no way out. That is how the 53 // "××× ××××ר" screen outlived its own fix by eleven days: a phone with 54 // no signal at the one moment the fixed worker tried to install kept the 55 // broken one indefinitely. 56 // 57 // The offline page is a convenience, not a precondition â without it the 58 // fetch handler answers with its own inline page (inlineOffline below). 59 // Shipping a working worker matters more than shipping a pretty offline 60 // screen. 61 try { 62 const cache = await caches.open(SHELL_CACHE); 63 // `reload` so a redeploy of the offline page actually lands. 64 await cache.add(new Request(OFFLINE_URL, { cache: "reload" })); 65 } catch { 66 /* no offline page this round; the fetch handler degrades on its own */ 67 } 68 await self.skipWaiting(); 69 })(), 70 ); 71}); 72 73self.addEventListener("activate", (event) => { 74 event.waitUntil( 75 (async () => { 76 const names = await caches.keys(); 77 await Promise.all( 78 names.filter((n) => !n.startsWith(VERSION)).map((n) => caches.delete(n)), 79 ); 80 await self.clients.claim(); 81 })(), 82 ); 83}); 84 85// ----------------------------------------------------------------- fetch 86 87function isCacheableAsset(url) { 88 if (IS_DEV) return false; 89 if (url.pathname.startsWith("/_next/static/")) return true; 90 if (url.pathname.startsWith("/brand/")) return true; 91 if (url.pathname === "/manifest.webmanifest") return true; 92 if (/\.(?:png|jpg|jpeg|svg|gif|webp|ico|woff2?)$/.test(url.pathname)) return true; 93 return false; 94} 95 96self.addEventListener("fetch", (event) => { 97 const req = event.request; 98 if (req.method !== "GET") return; 99 100 const url = new URL(req.url); 101 if (url.origin !== self.location.origin) return; 102 103 // Never let the cache anywhere near an API response. 104 if (url.pathname.startsWith("/api/")) return; 105 106 // The recovery route exists to unregister a worker that has gone wrong. 107 // Routing it through that same worker is how you build a trap with no exit. 108 if (url.pathname === "/sw-reset") return; 109 110 if (req.mode === "navigate") { 111 event.respondWith( 112 (async () => { 113 try {
114 // Note what is missing here: no cache.put(). A dashboard page is 115 // per-employee and full of student data; it stays in memory only. 116 const res = await fetch(req); 117 118 // ââ The mode-switch "no signal" bug ââââââââââââââââââââââââââââ 119 // A navigation request always carries redirect:"manual", so by the 120 // spec `fetch(req)` can only ever come back as an opaqueredirect â 121 // never with the redirected flag set. WebKit does not honour that: 122 // it follows the hop itself and hands us a response whose redirected 123 // flag IS set. Returning such a response from respondWith() is a 124 // hard error ("Response served by service worker has redirected flag 125 // set but request's redirect mode is not follow"), and it is thrown 126 // by respondWith, not by fetch â so the try/catch below never sees 127 // it and the navigation simply dies. Inside the Capacitor shell a 128 // dead navigation is painted by capacitor-shell/www/error.html, i.e. 129 // "××× ××××ר" â which is exactly what switching between × ×××× and 130 // ×××ת×ת ש×× produced: both /api/mentor-mode and /api/view-as answer 131 // their form POST with a 303, and that 303 is the hop WebKit eats. 132 // Reopening the app cleared it because a cold start loads server.url 133 // with no redirect anywhere in the chain. 134 // 135 // Re-issuing the final URL as a fresh redirect strips the flag. The 136 // browser then navigates there directly, this handler runs again for 137 // that URL, and the second time round there is no hop to follow. 138 if (res.redirected) return Response.redirect(res.url, 303); 139 140 return res; 141 } catch (err) { 142 // Cache Storage can fail on its own (evicted, corrupted, over 143 // quota). A rejection here would fail the navigation and paint the 144 // very dead end this branch exists to avoid, so it only costs us the 145 // nicer page. 146 let cached; 147 try { 148 cached = await caches.match(OFFLINE_URL); 149 } catch { 150 /* answer with the inline page */ 151 } 152 return cached ?? inlineOffline(err); 153 } 154 })(), 155 ); 156 return; 157 } 158 159 if (!isCacheableAsset(url)) return; 160 161 event.respondWith( 162 (async () => { 163 // Same reasoning as above: an asset Cache Storage cannot read is still 164 // on the network. 165 let cached; 166 try { 167 cached = await caches.match(req); 168 } catch { 169 /* fall through to the network */ 170 } 171 if (cached) return cached; 172 const res = await fetch(req); 173 if (res && res.ok && res.type === "basic") { 174 // Not awaited, and its failure swallowed: keeping a copy is a bonus, 175 // never a reason to hold up or fail the response the page waits for. 176 const copy = res.clone(); 177 caches.open(ASSET_CACHE).then((c) => c.put(req, copy)).catch(() => {}); 178 } 179 return res; 180 })(), 181 ); 182}); 183 184// ------------------------------------------------------------ last resort 185 186// What a navigation gets when the network is gone AND the offline page never 187// made it into the cache (the install could not fetch it, or Cache Storage 188// itself failed). Two things about it are deliberate: 189// - It answers 200, not 503. Inside the Capacitor shell any main-frame 190// answer of 400 or more is thrown away and replaced by 191// capacitor-shell/www/error.html, so the old inline 503 was never seen: 192// the phone painted the shell's own "××× ××××ר" instead. 193// - It links to /sw-reset. This page is served BY the worker; if the worker 194// is what has gone wrong, the one way out is the route it never handles. 195// The grey line at the bottom is for us: a screenshot of it says what failed, 196// whether the phone believed it was online, and which worker answered. 197function escapeHtml(s) { 198 return String(s).replace( 199 /[&<>"']/g, 200 (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], 201 ); 202} 203 204function inlineOffline(err) { 205 const name = err && err.name ? err.name : "Error";
206 const message = err && err.message ? err.message : String(err); 207 const online = typeof navigator !== "undefined" ? navigator.onLine : "?"; 208 const detail = escapeHtml(`${name}: ${message} · onLine=${online} · ${VERSION}`); 209 const html = `<!doctype html> 210<html lang="he" dir="rtl"> 211<head> 212<meta charset="utf-8"> 213<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> 214<title>××× ××××ר · ××× ××§</title> 215<style> 216body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center; 217 padding:24px;box-sizing:border-box;background:#EDF1F7;text-align:center; 218 font-family:system-ui,-apple-system,"Segoe UI",Rubik,Arial,sans-serif} 219main{width:100%;max-width:420px} 220h1{margin:0 0 8px;font-size:26px;font-weight:900;color:#0D294B} 221p{margin:0 0 20px;font-size:14px;line-height:1.7;color:#5C6B82} 222button{display:block;width:100%;padding:13px 20px;border:0;border-radius:16px; 223 background:#0D294B;color:#fff;font:inherit;font-size:15px;font-weight:800} 224a{display:block;margin:14px 0 0;font-size:13px;font-weight:700;color:#5C6B82} 225.detail{margin:24px 0 0;font-size:11px;line-height:1.4;color:#97A4B8;word-break:break-word; 226 font-family:ui-monospace,SFMono-Regular,Menlo,monospace} 227</style> 228</head> 229<body> 230<main> 231<h1>××× ××××ר ×××× ××¨× ×</h1> 232<p>×× ×צ××× × ×××××¢ ×שרת. ס×××× ×× ×©××ר עש×ת ש××ר×× ×××ש×ר ×××ש××× ××× ×ש×××××ר ××××ר.</p> 233<button type="button" onclick="location.reload()">× ×¡× ×©××</button> 234<a href="/sw-reset">××פ××קצ×× ×ª×§××¢×? ××פ×ס</a> 235<p dir="ltr" class="detail">${detail}</p> 236</main> 237</body> 238</html>`; 239 return new Response(html, { 240 status: 200, 241 headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }, 242 }); 243} 244 245// --------------------------------------------------------- background sync 246 247self.addEventListener("sync", (event) => { 248 if (event.tag !== SYNC_TAG) return; 249 event.waitUntil(syncFromWorker()); 250}); 251 252const DRAIN_REQUEST_MESSAGE = "znk-attendance-drain-request"; 253 254/** 255 * Prefer the page over the worker. 256 * 257 * When a window is open it holds the live roster, so letting *it* drain keeps 258 * every row's `marked_at` baseline exact â the worker would write the same 259 * rows but the page would keep an outdated baseline and start reporting 260 * phantom conflicts on the mentor's next tap. Only when nothing is open does 261 * the worker do the work itself; by the time a page opens again it renders 262 * fresh from the server anyway, so there is no stale baseline to inherit. 263 */ 264async function syncFromWorker() { 265 const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" }); 266 if (clients.length > 0) { 267 for (const c of clients) c.postMessage({ type: DRAIN_REQUEST_MESSAGE }); 268 return; 269 } 270 await drainQueue(); 271} 272 273function openDb() { 274 return new Promise((resolve, reject) => { 275 const req = indexedDB.open(DB_NAME, DB_VERSION); 276 req.onupgradeneeded = () => { 277 const db = req.result; 278 if (!db.objectStoreNames.contains(QUEUE_STORE)) db.createObjectStore(QUEUE_STORE, { keyPath: "key" }); 279 if (!db.objectStoreNames.contains(OUTCOME_STORE)) db.createObjectStore(OUTCOME_STORE, { keyPath: "key" }); 280 }; 281 req.onsuccess = () => resolve(req.result); 282 req.onerror = () => reject(req.error); 283 }); 284} 285 286function idb(db, store, mode, fn) { 287 return new Promise((resolve, reject) => { 288 const t = db.transaction(store, mode); 289 const r = fn(t.objectStore(store)); 290 r.onsuccess = () => resolve(r.result); 291 r.onerror = () => reject(r.error); 292 }); 293} 294 295/** 296 * The worker-side twin of drainQueue() in src/lib/offline/attendance-queue.ts. 297 * 298 * Running both is harmless and that is the point of the design: each item 299 * carries a stable (session|student) key, the server upserts on the matching 300 * unique constraint, and the baseline check rejects anything stale. A double 301 * drain produces one row, not two. 302 */ 303async function drainQueue() { 304 let db; 305 try { 306 db = await openDb(); 307 } catch { 308 return; 309 } 310 311 const items = (await idb(db, QUEUE_STORE, "readonly", (s) => s.getAll())) || []; 312 let changed = false;
313 314 for (const item of items) { 315 let status = 0; 316 let payload = {}; 317 try { 318 const res = await fetch("/api/attendance/mark", { 319 method: "POST", 320 headers: { "Content-Type": "application/json" }, 321 credentials: "same-origin", 322 body: JSON.stringify({ 323 session_id: item.session_id, 324 customer_id: item.customer_id, 325 status: item.status, 326 base_marked_at: item.base_marked_at, 327 }), 328 }); 329 status = res.status; 330 payload = await res.json().catch(() => ({})); 331 } catch { 332 status = 0; 333 } 334 335 // Still offline, or the session died â stop and leave the queue intact. 336 if (status === 0 || status === 401) break; 337 338 if (status >= 200 && status < 300) { 339 await idb(db, QUEUE_STORE, "readwrite", (s) => s.delete(item.key)); 340 changed = true; 341 continue; 342 } 343 344 if (status === 409) { 345 await idb(db, QUEUE_STORE, "readwrite", (s) => s.delete(item.key)); 346 await idb(db, OUTCOME_STORE, "readwrite", (s) => 347 s.put({ 348 key: item.key, 349 session_id: item.session_id, 350 customer_id: item.customer_id, 351 full_name: item.full_name, 352 kind: "conflict", 353 requested_status: item.status, 354 server_status: payload.server_status ?? null, 355 message: payload.error || "×ס×××× ×©×× × ×שרת ×××× ×©×××ש×ר ××× ×× ××§×××.", 356 at: new Date().toISOString(), 357 }), 358 ); 359 changed = true; 360 continue; 361 } 362 363 const attempts = (item.attempts || 0) + 1; 364 if (status === 400 || attempts >= MAX_ATTEMPTS) { 365 await idb(db, QUEUE_STORE, "readwrite", (s) => s.delete(item.key)); 366 await idb(db, OUTCOME_STORE, "readwrite", (s) => 367 s.put({ 368 key: item.key, 369 session_id: item.session_id, 370 customer_id: item.customer_id, 371 full_name: item.full_name, 372 kind: "failed", 373 requested_status: item.status, 374 server_status: null, 375 message: payload.error || `×ש×××¨× × ×ש×× (${status}).`, 376 at: new Date().toISOString(), 377 }), 378 ); 379 } else { 380 await idb(db, QUEUE_STORE, "readwrite", (s) => 381 s.put({ ...item, attempts, last_error: payload.error || `HTTP ${status}` }), 382 ); 383 } 384 changed = true; 385 } 386 387 db.close(); 388 389 if (!changed) return; 390 const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" }); 391 for (const c of clients) c.postMessage({ type: SYNC_DONE_MESSAGE }); 392}
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.