PageSourceSearch

https://zinuk-admin.vercel.app/sw.js

js zinuk-admin.vercel.app collected 2026-10-03 08:05:24 UTC 15,531 bytes, 392 lines download raw bytes

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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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.