208 lines
5.8 KiB
JavaScript
208 lines
5.8 KiB
JavaScript
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);
|
|
}
|