PageSourceSearch

https://prokact.ru/service-worker.js

js prokact.ru collected 2026-09-30 10:05:22 UTC 9,002 bytes, 274 lines download raw bytes

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.