// Service worker: кэш оболочки, пуши и переход по уведомлению (ADR-023). // // Оболочка отдаётся stale-while-revalidate: сначала из кэша, следом — // проверка у сервера. Всё под /api/ не кэшируется никогда: ни ответы, // ни ошибки — это чужая почта, а не оболочка. // // Имя кэша содержит версию; версия — константа, она меняется при релизе, // и старые кэши уходят в activate. const VERSION = "v4"; const CACHE = `bare-${VERSION}`; // Оболочка — всё, из чего клиент поднимается без сети. Список явный: // у Cache API нет масок, а угадывать нечего — файлов немного и они // перечислены в docs/plan.md (ADR-001). const SHELL = [ "/", "/app.css", "/manifest.json", "/js/api.js", "/js/crypto.js", "/js/db.js", "/js/export.js", "/js/main.js", "/js/pwa.js", "/js/sync.js", "/js/ulid.js", "/js/ui/auth.js", "/js/ui/chat.js", "/js/ui/chats.js", "/js/ui/contact.js", "/js/ui/dom.js", "/js/ui/members.js", "/js/ui/new.js", "/js/ui/settings.js", "/js/ui/shell.js", "/icons/icon.svg", "/icons/mark.svg", "/icons/icon-180.png", "/icons/icon-192.png", "/icons/icon-512.png", ]; // Иконка уведомления — знак из /icons/ (ADR-023). const ICON = "/icons/icon-192.png"; // Идентификатор чата из нагрузки пуша (ADR-023) и маршруты клиента // (docs/ui.md, «Каркас») — разные формы одного и того же; перевод // одной в другую — ADR-046. const DM = /^dm:([a-z0-9_]{2,32})$/; const ROOM = /^room:([A-Za-z0-9_-]{22})$/; self.addEventListener("install", (event) => { event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL))); }); // activate уносит кэши прежних версий: имя кэша содержит версию, и всё, // что названо иначе, — прошлый релиз. clients.claim берёт под контроль // уже открытую страницу: без этого первый запуск остался бы без кэша // и без перехода по уведомлению. self.addEventListener("activate", (event) => { event.waitUntil((async () => { for (const name of await caches.keys()) { if (name !== CACHE) { await caches.delete(name); } } await self.clients.claim(); })()); }); self.addEventListener("fetch", (event) => { const request = event.request; if (request.method !== "GET") { return; } const url = new URL(request.url); if (url.origin !== self.location.origin || !shell(url.pathname)) { return; } event.respondWith(revalidate(event, request, url.origin + url.pathname + url.search)); }); // shell — что относится к оболочке. Всё прочее идёт в сеть мимо кэша: // /api/ — потому что это данные (ADR-023), /sw.js — потому что его // обновление ведёт браузер, /healthz — потому что он про сервер. function shell(path) { return path === "/" || path === "/app.css" || path === "/manifest.json" || path.startsWith("/js/") || path.startsWith("/icons/"); } // revalidate — stale-while-revalidate. Ответ из кэша уходит сразу, запрос // к серверу идёт своим ходом и обновляет кэш. Сервер отдаёт статику // с ETag и Cache-Control: no-cache (docs/protocol.md), поэтому обычный // fetch — это условный запрос: неизменившийся файл стоит одного 304. // // Ключ кэша — адрес без фрагмента: у навигационного запроса в url лежит // маршрут (`/#/dm/marta`), и по самому запросу запись `/` подменялась бы // адресом последней перезагрузки. Сопоставление фрагмент и так // игнорирует, а вот caches.keys() должен говорить правду о том, что // лежит в оболочке (ADR-001). function revalidate(event, request, key) { return caches.open(CACHE).then(async (cache) => { const cached = await cache.match(key); const network = fetch(request).then((response) => { // Кладём только цельный свой ответ: чужие и частичные в оболочке // не бывают. if (response.ok && response.type === "basic") { cache.put(key, response.clone()); } return response; }); if (cached) { // Проверка переживёт ответ: без waitUntil браузер вправе усыпить // service worker сразу после отдачи страницы. event.waitUntil(network.catch(() => {})); return cached; } return network; }); } // push → уведомление. Содержимого сообщения в нагрузке нет и быть // не может: сервер его не знает (ADR-011). tag — идентификатор чата: // новое уведомление заменяет старое в том же чате (ADR-023). self.addEventListener("push", (event) => { const data = payload(event); if (data === null) { return; } event.waitUntil(self.registration.showNotification(data.title, { body: data.body, tag: data.chat, data: { chat: data.chat }, icon: ICON, lang: "ru", })); }); // payload разбирает нагрузку пуша: {title, body, chat} (ADR-023). // Чужого здесь не бывает — пуш подписан ключом сервера, — но показывать // неразобранное всё равно нечем. function payload(event) { let data = null; try { data = event.data ? event.data.json() : null; } catch { return null; } if (data === null || typeof data !== "object") { return null; } const { title, body, chat } = data; if (typeof title !== "string" || typeof body !== "string" || typeof chat !== "string") { return null; } if (title === "" || body === "" || chat === "") { return null; } return { title, body, chat }; } // notificationclick — фокус уже открытого окна с переходом на нужный чат // либо открытие нового (ADR-023). self.addEventListener("notificationclick", (event) => { event.notification.close(); event.waitUntil(open(route(event.notification.data?.chat))); }); // route переводит идентификатор чата в маршрут клиента (ADR-046). // Идентификатор не той формы открывает список. function route(chat) { if (typeof chat === "string") { const dm = DM.exec(chat); if (dm) { return `/#/dm/${dm[1]}`; } const room = ROOM.exec(chat); if (room) { return `/#/room/${room[1]}`; } } return "/#/"; } async function open(path) { const target = new URL(path, self.location.origin); const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); for (const client of windows) { if (new URL(client.url).origin !== target.origin) { continue; } try { await client.focus(); } catch { // Поднять окно не дали. Чат всё равно откроем: человек вернётся // в приложение сам и увидит его открытым там, где нужно. } try { // Маршрут живёт в hash: переход к нему — не перезагрузка, // а событие hashchange, которое разбирает роутер клиента. if (new URL(client.url).hash !== target.hash && typeof client.navigate === "function") { await client.navigate(target.href); } } catch { // Окно не наше или им уже распоряжаются: останется, где было. } return; } await self.clients.openWindow(target.href); }