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.