1// âââ Service Worker v5 ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2const CACHE_NAME = "prokact-v10"; 3const STATIC_CACHE = "prokact-static-v10"; 4const API_CACHE = "prokact-api-v10"; 5 6// Resources to pre-cache on install 7// NOTE: index.html is NOT pre-cached â it changes on every build. 8// The app shell is fetched live and cached via network-first strategy. 9const PRE_CACHE = [ 10 "/site.webmanifest", 11 "/favicon.ico", 12 "/favicon-96x96.png", 13 "/apple-touch-icon.png", 14]; 15 16// âââ Install: pre-cache static assets âââââââââââââââââââââââââââââââââââââââââ 17self.addEventListener("install", (event) => { 18 event.waitUntil( 19 caches 20 .open(STATIC_CACHE) 21 .then((cache) => { 22 return cache.addAll(PRE_CACHE).catch((err) => { 23 console.warn("[SW] Pre-cache failed:", err); 24 }); 25 }) 26 .then(() => self.skipWaiting()), 27 ); 28}); 29 30// âââ Activate: clean up old caches ââââââââââââââââââââââââââââââââââââââââââââ 31self.addEventListener("activate", (event) => { 32 const validCaches = [CACHE_NAME, STATIC_CACHE, API_CACHE]; 33 event.waitUntil( 34 caches 35 .keys() 36 .then((keys) => 37 Promise.all( 38 keys 39 .filter((key) => !validCaches.includes(key)) 40 .map((key) => caches.delete(key)), 41 ), 42 ) 43 .then(() => self.clients.claim()), 44 ); 45}); 46 47// âââ Fetch: network-first for API, cache-first for assets âââââââââââââââââââââ 48self.addEventListener("fetch", (event) => { 49 const { request } = event; 50 const url = new URL(request.url); 51 52 // Skip non-GET, cross-origin, and socket.io requests 53 if ( 54 request.method !== "GET" || 55 url.origin !== self.location.origin || 56 url.pathname.startsWith("/socket.io") 57 ) { 58 return; 59 } 60 61 // Sticker API: never cache (files change at runtime) 62 if (url.pathname.startsWith("/api/admin/stickers")) { 63 return; // pass through to network 64 } 65 66 // API routes: network-first with cache fallback. 67 // Podcast endpoints can be slow (large lists / files), give them more time. 68 if (url.pathname.startsWith("/api/podcasts")) { 69 event.respondWith(networkFirst(request, API_CACHE, 30000)); 70 return; 71 } 72 if (url.pathname.startsWith("/api/")) { 73 event.respondWith(networkFirst(request, API_CACHE, 15000)); 74 return; 75 } 76 77 // Audio/video streams: always network (don't cache large media in SW) 78 if ( 79 url.pathname.includes("stream") || 80 url.pathname.endsWith(".mp3") || 81 url.pathname.endsWith(".ogg") || 82 url.pathname.endsWith(".m4a") || 83 url.pathname.endsWith(".wav") || 84 url.pathname.endsWith(".flac") || 85 url.pathname.endsWith(".aac") || 86 url.pathname.endsWith(".opus") || 87 url.pathname.endsWith(".mp4") || 88 url.pathname.endsWith(".webm") || 89 url.pathname.endsWith(".mov") || 90 url.pathname.endsWith(".mkv") || 91 url.pathname.endsWith(".avi") || 92 url.pathname.endsWith(".m3u") || 93 url.pathname.endsWith(".m3u8") || 94 url.pathname.endsWith(".pls") 95 ) { 96 return; 97 } 98 99 // Stickers and user-uploaded files: never cache (updated at runtime) 100 if ( 101 url.pathname.startsWith("/stickers/") || 102 url.pathname.startsWith("/stickers-meta/") 103 ) { 104 return; // pass through to network, no SW caching 105 } 106 107 // Static assets: cache-first 108 if ( 109 url.pathname.match( 110 /\.(js|css|png|jpg|jpeg|webp|svg|ico|woff|woff2|ttf|eot)$/, 111 ) 112 ) { 113 event.respondWith(cacheFirst(request, STATIC_CACHE)); 114 return; 115 } 116 117 // HTML / navigation: network-first with cache fallback 118 event.respondWith(networkFirst(request, STATIC_CACHE, 4000)); 119}); 120 121// âââ Strategies âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 122async function cacheFirst(request, cacheName) { 123 const cached = await caches.match(request); 124 if (cached) return cached; 125 try { 126 const response = await fetch(request); 127 if (response.ok && response.status !== 206) { 128 const cache = await caches.open(cacheName); 129 cache.put(request, response.clone()); 130 } 131 return response; 132 } catch (err) { 133 return new Response("ÐÑлайн â ÑеÑÑÑÑ Ð½ÐµÐ´Ð¾ÑÑÑпен", { status: 503 }); 134 } 135} 136 137async function networkFirst(request, cacheName, timeoutMs = 5000) { 138 const controller = new AbortController(); 139 const timer = setTimeout(() => controller.abort(), timeoutMs); 140 try { 141 const response = await fetch(request, { signal: controller.signal }); 142 clearTimeout(timer); 143 if (response.ok && response.status !== 206) { 144 const cache = await caches.open(cacheName); 145 cache.put(request, response.clone()); 146 } 147 return response; 148 } catch (err) { 149 clearTimeout(timer); 150 const cached = await caches.match(request); 151 if (cached) return cached; 152 // For navigation requests, fall back to index.html 153 if (request.mode === "navigate") { 154 const fallback = await caches.match("/index.html"); 155 if (fallback) return fallback; 156 } 157 return new Response(JSON.stringify({ error: "ÐÑлайн", offline: true }), { 158 status: 503, 159 headers: { "Content-Type": "application/json" }, 160 }); 161 } 162} 163 164// âââ Push notifications ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 165self.addEventListener("push", (event) => { 166 if (!event.data) return; 167 168 let data = {}; 169 try { 170 data = event.data.json(); 171 } catch { 172 data = { title: "Уведомление", body: event.data.text(), type: "message" }; 173 } 174 175 const type = data.type || "message"; 176 const isCall = type === "call" || type === "group-call"; 177 const isPodcast = type === "podcast"; 178 179 const options = { 180 body: data.body || "", 181 icon: "/favicon-96x96.png", 182 badge: "/favicon-96x96.png", 183 data: { 184 url: data.url || "/chat", 185 type, 186 callId: data.callId || null, 187 callerId: data.callerId || null, 188 callType: data.callType || "audio", 189 }, 190 }; 191 192 if (isCall) { 193 // Call notification: stays on screen until user interacts, has action buttons 194 options.requireInteraction = true; 195 options.vibrate = [300, 150, 300, 150, 300, 150, 600]; 196 options.tag = `incoming-call-${data.callId || "call"}`; 197 options.renotify = true; 198 options.actions = [ 199 { action: "answer", title: "â ÐÑвеÑиÑÑ" }, 200 { action: "decline", title: "â ÐÑклониÑÑ" }, 201 ]; 202 } else if (isPodcast) { 203 // Podcast notification: own tag so it doesn't collapse with chat messages 204 options.tag = `podcast-${data.podcastId || "new"}`; 205 options.renotify = true; 206 options.vibrate = [200, 100, 200]; 207 } else { 208 // Message notification: grouped by sender, collapses old ones 209 options.tag = `msg-${data.senderId || "chat"}`; 210 options.renotify = true; 211 options.vibrate = [200, 100, 200]; 212 } 213 214 event.waitUntil( 215 self.registration.showNotification( 216 data.title || "ÐолодÑжное Радио", 217 options, 218 ), 219 ); 220}); 221 222// âââ Notification click ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 223self.addEventListener("notificationclick", (event) => { 224 event.notification.close(); 225 226 const { url, type }
226 = event.notification.data || {}; 227 const action = event.action; 228 229 // "Decline" from call notification â just close 230 if (action === "decline") return; 231 232 const targetUrl = url || "/chat"; 233 234 event.waitUntil( 235 clients 236 .matchAll({ type: "window", includeUncontrolled: true }) 237 .then((clientList) => { 238 // Find an existing window on the same origin 239 for (const client of clientList) { 240 try { 241 if ( 242 new URL(client.url).origin === self.location.origin && 243 "focus" in client 244 ) { 245 // Reuse a stable MessageChannel per client to avoid "message channel closed" 246 // race: the port is persisted on the client, SW sends with port, page receives 247 // and replies synchronously, SW resolves focus() â no dangling async listener. 248 if (!client._swChannel) { 249 client._swChannel = new MessageChannel(); 250 } 251 const port2 = client._swChannel.port2; 252 return new Promise((resolve) => { 253 port2.addEventListener("message", (e) => resolve(e.data), { 254 once: true, 255 }); 256 port2.start(); 257 client.postMessage({ type: "sw-navigate", url: targetUrl }, [ 258 port2, 259 ]); 260 client.focus(); 261 }); 262 } 263 } catch {} 264 } 265 // No open window â open a new one at the right URL 266 return clients.openWindow(targetUrl); 267 }), 268 ); 269}); 270 271// âââ Notification close (dismissed without clicking) ââââââââââââââââââââââââââ 272self.addEventListener("notificationclose", () => { 273 // Nothing to do; caller-side timeout handles missed calls 274});
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.