init
This commit is contained in:
207
frontend/public/sw.js
Normal file
207
frontend/public/sw.js
Normal file
@@ -0,0 +1,207 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user