PageSourceSearch

https://www.salescadia.com/sw.js

js salescadia.com collected 2026-10-09 22:58:47 UTC 8,338 bytes, 247 lines download raw bytes

1// Salescadia Service Worker — caching + push notifications
2// Bump CACHE_NAME on any release that must invalidate stale client caches. The `activate` handler below
3// deletes every cache whose key !== CACHE_NAME, so bumping this forces a clean slate for all clients on
4// their next visit (their browser re-fetches this file, installs the new SW, and purges the old cache).
5const CACHE_NAME = "mm-v2";
6const STATIC_ASSETS = ["/icon.svg", "/icons/icon-192.png", "/icons/icon-512.png"];
7
8// Install: pre-cache static assets
9self.addEventListener("install", (event) => {
10  event.waitUntil(
11    caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
12  );
13  self.skipWaiting();
14});
15
16// Activate: clean old caches
17self.addEventListener("activate", (event) => {
18  event.waitUntil(
19    caches.keys().then((keys) =>
20      Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
21    )
22  );
23  self.clients.claim();
24});
25
26// Fetch: network-first for pages/API, cache-first for static assets
27self.addEventListener("fetch", (event) => {
28  const url = new URL(event.request.url);
29
30  // Skip non-GET and cross-origin
31  if (event.request.method !== "GET" || url.origin !== self.location.origin) return;
32
33  // Static assets: cache-first
34  if (
35    url.pathname.startsWith("/icons/") ||
36    url.pathname.startsWith("/_next/static/") ||
37    url.pathname === "/icon.svg" ||
38    url.pathname === "/manifest.json"
39  ) {
40    event.respondWith(
41      caches.match(event.request).then((cached) => cached || fetch(event.request))
42    );
43    return;
44  }
45
46  // Everything else: network-first with offline fallback.
47  // The fallback MUST resolve to a real Response — caches.match() resolves to
48  // undefined for anything we never pre-cached (e.g. "/dashboard"), and
49  // respondWith(undefined) renders a blank white screen. Guarantee a Response.
50  if (event.request.mode === "navigate") {
51    event.respondWith(
52      fetch(event.request).catch(async () =>
53        (await caches.match(event.request)) ||
54        (await caches.match("/")) ||
55        new Response("You are offline.", {
56          status: 503,
57          headers: { "Content-Type": "text/plain" },
58        })
59      )
60    );
61    return;
62  }
63});
64
65// Push: show notification
66self.addEventListener("push", (event) => {
67  let data = { title: "Salescadia", body: "You have a new notification" };
68
69  try {
70    if (event.data) data = event.data.json();
71  } catch {
72    if (event.data) data.body = event.data.text();
73  }
74
75  const options = {
76    body: data.body,
77    icon: "/icons/icon-192.png",
78    badge: "/icons/icon-192.png",
79    tag: data.tag || "mm-default",
80    renotify: data.renotify !== undefined ? data.renotify : !!data.tag,
81    data: { url: data.url || "/dashboard/chat" },
82    actions: data.actions || [],
83  };
84
85  event.waitUntil(self.registration.showNotification(data.title, options));
86});
87
88// Notification click: focus or open the relevant page
89self.addEventListener("notificationclick", (event) => {
90  event.notification.close();
91
92  const url = event.notification.data?.url || "/dashboard/chat";
93  // Compare against the pathname only — notification URLs carry query strings
94  // (e.g. "?highlight=<id>"), so matching the full URL against a client's bare
95  // pathname never hit and every tap spawned a fresh (often blank) window.
96  const targetPath = new URL(url, self.location.origin).pathname;
97
98  event.waitUntil(
99    self.clients
100      .matchAll({ type: "window", includeUncontrolled: true })
101      .then((clients) => {
102        const match = clients.find(
103          (c) => new URL(c.url).pathname === targetPath && "focus" in c
104        );
105        if (match) return match.focus();
106        return self.clients.openWindow(url);
107      })
108  );
109});
110
111// ── Background Sync: upload pending recordings even after tab closes ──
112
113const REC_DB_NAME = "mm-recording-uploads";
114const REC_STORE = "pending";
115// MUST match DB_VERSION in src/lib/recording/resilient-upload.ts. Opening with
116// a lower version than the client throws VersionError, which silently breaks
117// every Background Sync upload. Keep these two in lockstep.
118const REC_DB_VERSION = 2;
119
120function openRecordingDb() {
121  return new Promise((resolve, reject) => {
122    const req = indexedDB.open(REC_DB_NAME, REC_DB_VERSION);
123    req.onupgradeneeded = () => {
124      if (!req.result.objectStoreNames.contains(REC_STORE)) {
125        req.result.createObjectStore(REC_STORE, { keyPath: "appointmentId" });
126      }
127    };
128    req.onsuccess = () => resolve(req.result);
129    req.onerror = () => reject(req.error);
130  });
131}
132
133function swGetAllPending() {
134  return openRecordingDb().then(
135    (db) =>
136      new Promise((resolve) => {
137        const tx = db.transaction(REC_STORE, "readonly");
138        const req = tx.objectStore(REC_STORE).getAll();
139        req.onsuccess = () => { db.close(); resolve(req.result || []); };
140        req.onerror = () => { db.close(); resolve([]); };
141      })
142  );
143}
144
145function swRemovePending(appointmentId) {
146  return openRecordingDb().then(
147    (db) =>
148      new Promise((resolve) => {
149        const tx = db.transaction(REC_STORE, "readwrite");
150        tx.objectStore(REC_STORE).delete(appointmentId);
151        tx.oncomplete = () => { db.close(); resolve(); };
152        tx.onerror = () => { db.close(); resolve(); };
153      })
154  );
155}
156
157async function swUploadRecording(pending) {
158  const { appointmentId, audioBlob, videoBlob, totalSize, authToken, hostKey } = pending;
159
160  const hasVideo = videoBlob && videoBlob.size > 0;
161  const primaryBlob = hasVideo ? videoBlob : audioBlob;
162  const trackLabel = hasVideo ? "video" : "mixed";
163  // Strip the codecs suffix (";codecs=...") exactly like the foreground
164  // uploader (resilient-upload.ts): the storage bucket's MIME allowlist holds
165  // base types, and codec strings are unbounded — "video/webm;codecs=av01..."
166  // gets a 400 InvalidMimeType and the recording never uploads.
167  const rawMime = hasVideo ? (videoBlob.type || "video/webm") : (audioBlob.type || "audio/webm");
168  const mime = rawMime.split(";")[0].trim();
169
170  // The tab is gone, so there's no same-origin Supabase session cookie to
171  // rely on — and on the cross-origin meetmatch.live meeting origin there
172  // never was one. Forward the credentials persisted with the upload record
173  // (Supabase JWT and/or HMAC host-key) exactly like executeUpload() does,
174  // otherwise the upload-url request 401/403s and the recording is dropped.
175  const headers = { "Content-Type": "application/json" };
176  if (authToken) headers["X-Upload-Auth"] = authToken;
177  if (hostKey) headers["X-Host-Key"] = hostKey;
178
179  const urlRes = await fetch("/api/personal-room/upload-url", {
180    method: "POST",
181    headers,
182    body: JSON.stringify({ appointmentId, trackLabel, fileSizeBytes: totalSize }),
183    credentials: "include",
184  });
185
186  if (!urlRes.ok) throw new Error("Failed to get upload URL");
187  const urlData = await urlRes.json();
188
189  await fetch(urlData.signedUrl, {
190    method: "PUT",
191    body: primaryBlob,
192    headers: { "Content-Type": mime },
193  });
194
195  const processBody = {
196    appointmentId,
197    storagePath: urlData.storagePath,
198    fileSizeBytes: totalSize,
199  };
200  if (hasVideo) {
201    processBody.videoStoragePath = urlData.storagePath;
202  }
203
204  await fetch("/api/personal-room/process-recording", {
205    method: "POST",
206    headers,
207    body: JSON.stringify(processBody),
208    credentials: "include",
209  });
210}
211
212self.addEventListener("sync", (event) => {
213  if (event.tag === "upload-recording") {
214    event.waitUntil(
215      (async () => {
216        let pending;
217        try {
218          pending = await swGetAllPending();
219        } catch {
220          return;
221        }
222
223        const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000;
224
225        for (const p of pending) {
226          if (p.createdAt <= twoHoursAgo) {
227            await swRemovePending(p.appointmentId);
228            continue;
229          }
230
231          // Skip blobs with no data
232          if (!p.audioBlob || (p.audioBlob.size === 0 && (!p.videoBlob || p.videoBlob.size === 0))) {
233            await swRemovePending(p.appointmentId);
234            continue;
235          }
236
237          try {
238            await swUploadRecording(p);
239            await swRemovePending(p.appointmentId);
240          } catch (err) {
241            console.error("[SW Sync] Upload failed, will retry:", err);
242          }
243        }
244      })()
245    );
246  }
247});

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.