Files
bare/web/sw.js
T
mayatnikovandClaude Opus 5 8f67f4aa4d Этап 4: PWA и пуши — service worker, Web Push с VAPID, баннер установки
Сервер: пакет push на webpush-go (третья и последняя прямая зависимость),
подписка устройства, правило ADR-023 «пуш только молчащему устройству и только
один» через атомарный захват push_pending, удаление подписки на 404 и 410,
TTL сутки, urgency normal. В нагрузке только {title, body, chat} — тело
собирается из константы, плейнтекст туда не попадает даже по ошибке.

Клиент: service worker с версионированным кэшем оболочки и никогда — /api/*,
push и notificationclick, подписка на VAPID-ключ сервера, запрос разрешения
после первого отправленного сообщения, разделы настроек «уведомления»
и «установить приложение», баннер установки на iOS.

ADR-045: пуш адресован получателю — по букве ADR-023 он уходил бы и молчащему
устройству отправителя с бессмысленным заголовком из собственного ника.
ADR-047: сервер ходит на endpoint подписки, который выбирает браузер. Проверка
«только https» обходилась редиректом, а имя могло смотреть внутрь сети — теперь
запрет редиректов и проверка разрешённого адреса на уровне сокета.
ADR-048: пределы отправки — недоступный push-сервис одного аккаунта больше
не съедает пуши всего сервера.
ADR-049: явно выключенные уведомления сами не включаются обратно.

Попутно: webpush-go дописывает набивку в переданный срез, а одна нагрузка
уходила всем устройствам сообщения — гонка, пойманная go test -race.
Теперь у каждого задания своя копия.

Приёмка на боевом: подписки, hasPush, чужое устройство, Origin, оболочка
из девяти файлов, Service-Worker-Allowed. Отдельно шесть непубличных адресов
и endpoint на 3 КиБ — все отбиты.

Чеклист ручной проверки на iPhone и Android — в docs/plan.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
2026-08-23 00:50:58 +03:00

214 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Service worker: кэш оболочки, пуши и переход по уведомлению (ADR-023).
//
// Оболочка отдаётся stale-while-revalidate: сначала из кэша, следом —
// проверка у сервера. Всё под /api/ не кэшируется никогда: ни ответы,
// ни ошибки — это чужая почта, а не оболочка.
//
// Имя кэша содержит версию; версия — константа, она меняется при релизе,
// и старые кэши уходят в activate.
const VERSION = "v2";
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/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);
}