const PUSH_CONTEXT_CACHE = "push-context-v1"; const PUSH_CONTEXT_URL = "/__push-context__"; self.addEventListener("install", () => { self.skipWaiting(); }); self.addEventListener("message", (event) => { if (event.data?.type !== "push-context-sync") { return; } event.waitUntil(savePushContext(event.data.payload)); }); self.addEventListener("activate", (event) => { event.waitUntil(self.clients.claim()); }); self.addEventListener("push", (event) => { const data = event.data ? event.data.json() : {}; const title = data.title || "Новый сигнал"; const body = data.body || "Появился новый матч в списке сигналов"; const url = data?.data?.url || data.url || "/"; const tag = typeof data.tag === "string" && data.tag.trim().length > 0 ? data.tag : undefined; const shouldRenotify = Boolean(tag && data.renotify); const notificationOptions = { body, requireInteraction: false, icon: "/icons/app-icon-192.png", badge: "/icons/app-badge.png", data: { url, ...(data?.data || {}) }, vibrate: [200, 100, 200], timestamp: Date.now(), ...(tag ? { tag } : {}), ...(tag ? { renotify: shouldRenotify } : {}) }; event.waitUntil( self.registration.showNotification(title, notificationOptions) ); }); self.addEventListener("notificationclick", (event) => { event.notification.close(); const url = event.notification.data?.url || "/"; event.waitUntil( clients.matchAll({ type: "window", includeUncontrolled: true }).then((windowClients) => { const sameOriginClients = windowClients.filter((client) => { try { return new URL(client.url).origin === self.location.origin; } catch { return false; } }); const preferredClient = sameOriginClients.find((client) => client.visibilityState === "visible" || client.focused) || sameOriginClients[0]; if (preferredClient) { preferredClient.postMessage({ type: "push-notification-click", url }); return preferredClient.focus(); } for (const client of windowClients) { if ("focus" in client) { client.navigate(url); return client.focus(); } } return clients.openWindow(url); }) ); }); self.addEventListener("pushsubscriptionchange", (event) => { event.waitUntil( resubscribeAfterChange(event) ); }); async function savePushContext(nextContext) { const cache = await caches.open(PUSH_CONTEXT_CACHE); const currentContext = await readPushContext(); const payload = { ...(currentContext || {}), ...(nextContext || {}) }; await cache.put( PUSH_CONTEXT_URL, new Response(JSON.stringify(payload), { headers: { "Content-Type": "application/json" } }) ); } async function readPushContext() { const cache = await caches.open(PUSH_CONTEXT_CACHE); const response = await cache.match(PUSH_CONTEXT_URL); if (!response) { return null; } try { return await response.json(); } catch { return null; } } function urlBase64ToUint8Array(base64String) { const normalizedInput = typeof base64String === "string" ? base64String.trim() : ""; if (!normalizedInput) { throw new Error("Missing VAPID public key"); } const padding = "=".repeat((4 - (normalizedInput.length % 4)) % 4); const base64 = (normalizedInput + padding).replace(/-/g, "+").replace(/_/g, "/"); const rawData = atob(base64); return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); } async function fetchApplicationServerKey(apiBase) { if (!apiBase) { throw new Error("Push API base is not configured in service worker context"); } const response = await fetch(`${apiBase}/vapid-public-key`, { method: "GET", credentials: "include" }); if (!response.ok) { throw new Error(`Failed to load VAPID public key: ${response.status}`); } const payload = await response.json(); return urlBase64ToUint8Array(payload.publicKey); } async function syncSubscriptionWithServer(context, subscription, previousEndpoint) { if (!context?.apiBase) { throw new Error("Push API base is not configured in service worker context"); } const serializedSubscription = subscription.toJSON(); if (!serializedSubscription.endpoint || !serializedSubscription.keys?.p256dh || !serializedSubscription.keys?.auth) { throw new Error("Browser returned an incomplete push subscription"); } const isAuthenticated = Boolean(context.isAuthenticated); const endpoint = isAuthenticated ? "/me/push-subscriptions" : "/public/push-subscriptions"; if (!isAuthenticated && !context.anonymousClientId) { throw new Error("Anonymous push client id is missing in service worker context"); } const headers = new Headers({ "Content-Type": "application/json" }); const response = await fetch(`${context.apiBase}${endpoint}`, { method: "POST", credentials: "include", headers, body: JSON.stringify({ ...(isAuthenticated ? {} : { clientId: context.anonymousClientId }), ...serializedSubscription, ...(previousEndpoint && previousEndpoint !== serializedSubscription.endpoint ? { previousEndpoint } : {}) }) }); if (!response.ok) { throw new Error(`Failed to sync rotated subscription: ${response.status}`); } } async function resubscribeAfterChange(event) { const context = await readPushContext(); if (!context) { return; } const previousEndpoint = event.oldSubscription?.endpoint; const subscription = event.newSubscription || (await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: await fetchApplicationServerKey(context.apiBase) })); await syncSubscriptionWithServer(context, subscription, previousEndpoint); }