Files
bare/web/sw.js
T
mayatnikovandClaude Opus 5 0c878477d2 Этап 5: история — экспорт и импорт .bare, устройства, место, пагинация
Экспорт: ключ архива из секрета аккаунта через HKDF, заголовок ровно 65 байт
(magic, версия, соль, 32 сырых байта отпечатка владельца, iv) и он же целиком
AAD шифротекста. Ника владельца в файле нет. Импорт сверяет отпечаток до
расшифровки, сливает идемпотентно по id, а записи peers берёт только для
ников, которых в локальном TOFU ещё нет: архивом доверие к ключу не перебить.

Настройки: устройства с датой и пометкой «это устройство», «занято N МБ»,
кнопка «экспортировать» в подтверждении выхода и в подтверждении входа
под другим ником — долг этапа 1 и обещание ADR-029 закрыты.
Лента: страницы по 50 с подгрузкой вверх без прыжка прокрутки; новая
страница вставляется, а не пересобирает ленту.

ADR-050: импорт не перезаписывает лежащую запись — у своей есть состояние
отправки, которого в архиве нет.
ADR-054: архив — недоверенный ввод. Ревью собрало архивы с ts вне диапазона
Date, мусорным lastId, ником с bidi-переопределением и roomId с обходом пути:
каждый из них навсегда ломал ленту или счётчик. Теперь форма ника, roomId,
id, ts и автора проверяется, а lastId из файла не читается вовсе.
ADR-051, 052, 053: тексты и кнопки, устройства и место, страницы ленты
без виртуализации.

Приёмка: формат сверен побайтно на модулях, скачанных с боевого сервера, —
смещения заголовка, отпечаток сырыми байтами, новые соль и iv на каждый
экспорт, подмена любого байта заголовка ломает расшифровку, чужой секрет
не открывает, семь видов битых файлов отвергнуты.

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

215 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 = "v3";
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);
}