Этап 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
This commit is contained in:
@@ -1,8 +1,213 @@
|
||||
// service worker. пока пустой: кэш оболочки и пуши — этап 4 (ADR-023).
|
||||
// версия кэша меняется при релизе.
|
||||
// Service worker: кэш оболочки, пуши и переход по уведомлению (ADR-023).
|
||||
//
|
||||
// Оболочка отдаётся stale-while-revalidate: сначала из кэша, следом —
|
||||
// проверка у сервера. Всё под /api/ не кэшируется никогда: ни ответы,
|
||||
// ни ошибки — это чужая почта, а не оболочка.
|
||||
//
|
||||
// Имя кэша содержит версию; версия — константа, она меняется при релизе,
|
||||
// и старые кэши уходят в activate.
|
||||
|
||||
const CACHE = "bare-v1";
|
||||
const VERSION = "v2";
|
||||
const CACHE = `bare-${VERSION}`;
|
||||
|
||||
self.addEventListener("install", () => {});
|
||||
// Оболочка — всё, из чего клиент поднимается без сети. Список явный:
|
||||
// у 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",
|
||||
];
|
||||
|
||||
self.addEventListener("activate", () => {});
|
||||
// Иконка уведомления — знак из /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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user