Сервер: пакет 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
272 lines
11 KiB
JavaScript
272 lines
11 KiB
JavaScript
// Обёртки над fetch и поток событий. Форма запросов и ответов —
|
||
// docs/protocol.md: JSON в обе стороны, cookie сессии, ошибка —
|
||
// {error, message}.
|
||
|
||
// MAX_ACK — сколько идентификаторов принимает один POST /api/ack
|
||
// (docs/protocol.md, «Сообщения»).
|
||
export const MAX_ACK = 500;
|
||
|
||
// ApiError — ответ сервера с кодом из перечня docs/protocol.md.
|
||
export class ApiError extends Error {
|
||
constructor(code, message, status, field) {
|
||
super(message || code);
|
||
this.name = "ApiError";
|
||
this.code = code;
|
||
this.status = status;
|
||
this.field = field;
|
||
}
|
||
}
|
||
|
||
// NetworkError — запрос не дошёл: сети нет, сервер не ответил.
|
||
// Это состояние клиента, а не код протокола.
|
||
export class NetworkError extends Error {
|
||
constructor() {
|
||
super("нет соединения");
|
||
this.name = "NetworkError";
|
||
}
|
||
}
|
||
|
||
// Тексты состояний и ошибок — docs/ui.md и ADR-028.
|
||
const TEXT = {
|
||
invalid_credentials: "неверный ник или пароль",
|
||
nick_taken: "ник занят",
|
||
invalid_nick: "ник: 2–32 символа, a–z, 0–9, _",
|
||
invite_required: "нужен инвайт-код",
|
||
invalid_invite: "инвайт-код не подходит",
|
||
rate_limited: "слишком часто, попробуйте позже",
|
||
unknown_user: "такого ника нет",
|
||
self: "нельзя писать себе",
|
||
clock_skew: "проверьте часы на устройстве: расхождение больше 5 минут",
|
||
};
|
||
|
||
export function errorText(err) {
|
||
if (err instanceof NetworkError) {
|
||
return "нет соединения";
|
||
}
|
||
if (err instanceof ApiError && TEXT[err.code]) {
|
||
return TEXT[err.code];
|
||
}
|
||
return "сервер не справился, попробуйте позже";
|
||
}
|
||
|
||
// expired вызывается, когда сервер сказал «нужен вход»: сессия истекла
|
||
// или её завершили с другого устройства. IndexedDB при этом не трогается
|
||
// (docs/ui.md, «Сеть и состояния»).
|
||
let expired = () => {};
|
||
|
||
export function onSessionExpired(handler) {
|
||
expired = handler;
|
||
}
|
||
|
||
// quiet: не звать expired() на 401 unauthenticated. Нужно ровно там, где
|
||
// «сессии нет» — не конец сеанса, а ожидаемый ответ (dropSession).
|
||
// device: заголовок X-Device — он обязателен там, где важно, с какого
|
||
// устройства пришёл запрос (docs/protocol.md, «Общие правила»).
|
||
async function request(method, path, body, { quiet = false, device = null } = {}) {
|
||
const init = { method, credentials: "same-origin", cache: "no-store" };
|
||
const headers = {};
|
||
if (body !== undefined) {
|
||
headers["Content-Type"] = "application/json";
|
||
init.body = JSON.stringify(body);
|
||
}
|
||
if (device) {
|
||
headers["X-Device"] = device;
|
||
}
|
||
if (Object.keys(headers).length > 0) {
|
||
init.headers = headers;
|
||
}
|
||
let response;
|
||
try {
|
||
response = await fetch(path, init);
|
||
} catch {
|
||
throw new NetworkError();
|
||
}
|
||
|
||
let data = null;
|
||
if ((response.headers.get("Content-Type") ?? "").startsWith("application/json")) {
|
||
data = await response.json().catch(() => null);
|
||
}
|
||
if (response.ok) {
|
||
return data;
|
||
}
|
||
|
||
const code = typeof data?.error === "string" ? data.error : "internal";
|
||
// Отличаем истёкшую сессию от неверного пароля: 401 invalid_credentials —
|
||
// обычная ошибка формы входа, 401 unauthenticated — выход на экран входа.
|
||
if (code === "unauthenticated" && !quiet) {
|
||
expired();
|
||
}
|
||
throw new ApiError(code, data?.message, response.status, data?.field);
|
||
}
|
||
|
||
export function config() {
|
||
return request("GET", "/api/config");
|
||
}
|
||
|
||
export function kdf(nick) {
|
||
return request("GET", `/api/kdf?nick=${encodeURIComponent(nick)}`);
|
||
}
|
||
|
||
export function register(body) {
|
||
return request("POST", "/api/register", body);
|
||
}
|
||
|
||
export function login(nick, authKey) {
|
||
return request("POST", "/api/login", { nick, authKey });
|
||
}
|
||
|
||
export function me() {
|
||
return request("GET", "/api/me");
|
||
}
|
||
|
||
// dropSession — служебный выход перед повторным входом (ADR-031). Смена
|
||
// пароля и удаление аккаунта входят заново, а вход перезаписывает cookie:
|
||
// прежнюю сессию закрываем сами, пока её токен ещё при нас.
|
||
//
|
||
// 401 unauthenticated здесь означает «сессии и так нет» — это успех, а не
|
||
// конец сеанса: следующим шагом идёт login, он заведёт новую. Остальные
|
||
// отказы поднимаются наверх: при живой сессии входить заново нельзя,
|
||
// её строка осталась бы на сервере без владельца.
|
||
export async function dropSession() {
|
||
try {
|
||
await request("POST", "/api/logout", undefined, { quiet: true });
|
||
} catch (err) {
|
||
if (!(err instanceof ApiError) || err.code !== "unauthenticated") {
|
||
throw err;
|
||
}
|
||
}
|
||
}
|
||
|
||
export function password(body) {
|
||
return request("POST", "/api/password", body);
|
||
}
|
||
|
||
export function deleteMe(authKey) {
|
||
return request("DELETE", "/api/me", { authKey });
|
||
}
|
||
|
||
export function user(nick) {
|
||
return request("GET", `/api/users/${encodeURIComponent(nick)}`);
|
||
}
|
||
|
||
// --- устройства --------------------------------------------------------
|
||
|
||
// registerDevice — 201 при создании, 200 если устройство уже наше,
|
||
// 409 device_conflict, если идентификатор занят другим (ADR-017).
|
||
export function registerDevice(id) {
|
||
return request("POST", "/api/devices", { id });
|
||
}
|
||
|
||
export function devices() {
|
||
return request("GET", "/api/devices");
|
||
}
|
||
|
||
export function removeDevice(id) {
|
||
return request("DELETE", `/api/devices/${encodeURIComponent(id)}`);
|
||
}
|
||
|
||
// setPush и clearPush — push-подписка устройства (ADR-023). Подписка
|
||
// принадлежит устройству, поэтому устройство идёт и в пути, и в заголовке:
|
||
// чужому подписку не поставить (docs/protocol.md, «Устройства»).
|
||
export function setPush(device, subscription) {
|
||
return request("PUT", `/api/devices/${encodeURIComponent(device)}/push`, { subscription }, { device });
|
||
}
|
||
|
||
export function clearPush(device) {
|
||
return request("DELETE", `/api/devices/${encodeURIComponent(device)}/push`, undefined, { device });
|
||
}
|
||
|
||
// --- контакты ----------------------------------------------------------
|
||
|
||
export function contacts() {
|
||
return request("GET", "/api/contacts");
|
||
}
|
||
|
||
// addContact заводит строку списка чатов и отдаёт публичный ключ
|
||
// собеседника: 404 unknown_user, 400 self (ADR-019).
|
||
export function addContact(nick) {
|
||
return request("POST", "/api/contacts", { nick });
|
||
}
|
||
|
||
export function removeContact(nick) {
|
||
return request("DELETE", `/api/contacts/${encodeURIComponent(nick)}`);
|
||
}
|
||
|
||
// --- комнаты -----------------------------------------------------------
|
||
|
||
// rooms — комнаты, где мы участники, каждая с нашим текущим завёрнутым
|
||
// ключом (docs/protocol.md, «Комнаты»).
|
||
export function rooms() {
|
||
return request("GET", "/api/rooms");
|
||
}
|
||
|
||
// createRoom заводит комнату. Идентификатор генерирует клиент: ключ
|
||
// заворачивается до запроса и привязан к roomId (ADR-037). Занятый
|
||
// идентификатор — 409 room_conflict, берётся новый.
|
||
//
|
||
// X-Device передаётся, чтобы это же устройство не получило комнату ещё
|
||
// и событием: она приходит ответом (docs/protocol.md, «Комнаты»).
|
||
export function createRoom(device, body) {
|
||
return request("POST", "/api/rooms", body, { device });
|
||
}
|
||
|
||
// changeMembers — смена состава и rekey одним запросом (ADR-018).
|
||
export function changeMembers(id, body) {
|
||
return request("POST", `/api/rooms/${encodeURIComponent(id)}/members`, body);
|
||
}
|
||
|
||
// leaveRoom — выход из комнаты. X-Device передаётся по той же причине,
|
||
// что и при создании: комната уходит из списка здесь же, а другим
|
||
// устройствам вышедшего сервер шлёт room_left (ADR-041).
|
||
export function leaveRoom(device, id) {
|
||
return request("POST", `/api/rooms/${encodeURIComponent(id)}/leave`, undefined, { device });
|
||
}
|
||
|
||
export function removeRoom(id) {
|
||
return request("DELETE", `/api/rooms/${encodeURIComponent(id)}`);
|
||
}
|
||
|
||
// --- сообщения ---------------------------------------------------------
|
||
|
||
// sendMessage отдаёт конверт серверу; from и ts он поставит сам (ADR-017).
|
||
// Ответ — 202 {id, ts}.
|
||
export function sendMessage(device, envelope) {
|
||
return request("POST", "/api/messages", envelope, { device });
|
||
}
|
||
|
||
// ack подтверждает запись сообщений в IndexedDB: сервер убирает их
|
||
// из очереди устройства (docs/storage.md). Не больше MAX_ACK за раз.
|
||
export function ack(device, ids) {
|
||
return request("POST", "/api/ack", { ids }, { device });
|
||
}
|
||
|
||
// --- события -----------------------------------------------------------
|
||
|
||
// stream открывает поток событий устройства (docs/protocol.md, «События»).
|
||
// Устройство передаётся в query: EventSource не умеет заголовки.
|
||
//
|
||
// Переподключение делает браузер сам. Ответ не 200 он считает
|
||
// окончательным отказом и больше не подключается — это видно
|
||
// по readyState CLOSED и передаётся в handlers.error вторым состоянием.
|
||
//
|
||
// Отдаёт функцию закрытия потока.
|
||
export function stream(device, handlers) {
|
||
const source = new EventSource(`/api/events?device=${encodeURIComponent(device)}`);
|
||
source.addEventListener("msg", (event) => handlers.msg(parse(event.data)));
|
||
// room и room_left в очередь не кладутся: пропуск во время офлайна
|
||
// чинится перечитыванием GET /api/rooms после ready (docs/protocol.md).
|
||
source.addEventListener("room", (event) => handlers.room(parse(event.data)));
|
||
source.addEventListener("room_left", (event) => handlers.roomLeft(parse(event.data)));
|
||
source.addEventListener("ready", () => handlers.ready());
|
||
source.addEventListener("error", () => handlers.error(source.readyState === EventSource.CLOSED));
|
||
return () => source.close();
|
||
}
|
||
|
||
function parse(data) {
|
||
try {
|
||
return JSON.parse(data);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|