Files
bare/web/js/sync.js
T
mayatnikovandClaude Opus 5 63a7a1ef52 Этап 2: чат 1:1 — устройства, очередь, SSE, шифрование сообщений
Сервер: регистрация устройств и X-Device, hub с одним потоком на устройство,
очередь per-device с фан-аутом без эха отправителю, POST /api/messages
с проверками в порядке protocol.md, ACK, SSE с воспроизведением очереди,
ready и пингом раз в 20 секунд, контакты в обе стороны при первом сообщении,
лимит 30 сообщений в минуту.

Клиент: ULID, ключ 1:1 из ECDH через HKDF, шифрование конверта с AAD,
sync.js как единственный писатель в IndexedDB, ACK строго после записи,
список чатов, экран чата по эталону, разделители дат и «новые»,
pending и failed с повтором, полоса «нет соединения».

ADR-033: у неотправленного есть текст отказа — clock_skew стало видно.
ADR-034: входящее с известным id не перезаписывает запись. Собеседник знает
открытый id конверта и подменял им чужое сообщение в чужой истории — вплоть
до стирания своего присланного, чего «удалить у всех не существует» не допускает.
ADR-035: один поток событий на браузерный профиль (locks + BroadcastChannel):
две вкладки отбирали поток друг у друга и оставались без живой доставки.
ADR-036: повтор отправки сохраняет ULID, пока он в пределах окна часов, —
иначе потерянный ответ давал у собеседника два сообщения вместо одного.

Приёмка на боевом сервере: два аккаунта, пять устройств, живая доставка,
копия на второе устройство, очередь офлайн-устройству, ACK, подмена from
игнорируется, чужой deviceId и запрос без Origin отбиваются, плейнтекста
в базе и WAL ноль вхождений.

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

912 lines
34 KiB
JavaScript

// Транспорт и данные чата: устройство, поток событий, приём и отправка.
// Экраны берут отсюда данные и сюда же отдают действия; в db.js и api.js
// они не ходят — пишет в базу только этот модуль.
//
// Правила — docs/protocol.md («События», «Сообщения») и docs/storage.md:
// ACK уходит только после успешной записи в IndexedDB, исходящее живёт
// в pending до 202 и держится за свой ULID, пока время в нём годится
// серверу; отвергнутый по часам переиспользованный id меняется на свежий
// один раз (ADR-036).
import * as api from "./api.js";
import { ApiError, NetworkError } from "./api.js";
import * as db from "./db.js";
import {
DM_KEY_ID,
dmKey,
dmLabel,
fingerprintOf,
newId,
openMessage,
sealMessage,
} from "./crypto.js";
import { ulid, ulidTime, validUlid } from "./ulid.js";
// Пауза перед восстановлением закрытого потока: удваивается, пока
// не упрётся в предел. Живой ready сбрасывает её обратно.
const RETRY_MIN = 1000;
const RETRY_MAX = 30000;
// Владение потоком одно на браузерный профиль: устройство у вкладок общее,
// а соединение на устройство сервер держит одно (ADR-035).
const STREAM_LOCK = "bare-stream";
const CHANNEL = "bare";
// Оба API нужны вместе: замок выбирает владельца потока, канал раздаёт
// его находки остальным вкладкам. Нет хотя бы одного — работаем как
// одна вкладка (ADR-035).
const shared = typeof BroadcastChannel === "function" && !!navigator.locks;
const state = {
running: false,
nick: null,
privateKey: null,
device: null,
close: null, // закрыть поток событий
online: false,
timer: null,
wait: RETRY_MIN,
// Владение потоком: release отпускает замок, claim отменяет ожидание.
release: null,
claim: null,
channel: null,
// Ключи личных чатов — только в памяти: в IndexedDB они не пишутся,
// а выводятся заново из peers (docs/crypto.md, «Чат 1:1»).
keys: new Map(),
// Неотправленное. Полный проход по messages делается один раз при
// старте: индекса по статусу в схеме нет (docs/storage.md).
pending: new Set(),
// Конверты, пришедшие по SSE и ещё не разобранные.
inbox: [],
scheduled: false,
// Отложенный разбор конвертов, которые сейчас не разобрать.
inboxTimer: null,
hold: RETRY_MIN,
};
// --- события для экранов -----------------------------------------------
const bus = new EventTarget();
// on подписывает обработчик и отдаёт функцию отписки. События три:
//
// "net" {online} — доходят ли запросы до сервера
// "chats" {} — список чатов изменился
// "messages" {chatId, ids, removed} — в чате появились, изменились
// или исчезли сообщения
//
// removed непуст, только когда повтор отправки выдал сообщению новый
// ULID: старую запись из ленты надо убрать. Обычный повтор идёт с прежним
// идентификатором, removed пуст, и лента не перерисовывается (ADR-036).
export function on(type, handler) {
const wrapped = (event) => handler(event.detail);
bus.addEventListener(type, wrapped);
return () => bus.removeEventListener(type, wrapped);
}
function emit(type, detail = {}) {
bus.dispatchEvent(new CustomEvent(type, { detail }));
}
// notify рассылает изменения: экрану чата нужна лента, сайдбару — список.
// Те же изменения уходят соседним вкладкам: поток событий у профиля один,
// а база общая (ADR-035).
function notify(messages, removed = []) {
const byChat = new Map();
const slot = (chatId) => {
if (!byChat.has(chatId)) {
byChat.set(chatId, { chatId, ids: [], removed: [] });
}
return byChat.get(chatId);
};
for (const m of messages) {
slot(m.chatId).ids.push(m.id);
}
for (const r of removed) {
slot(r.chatId).removed.push(r.id);
}
const details = [...byChat.values()];
for (const detail of details) {
emit("messages", detail);
}
emit("chats");
share({
kind: "changed",
details,
// Отправленное и похороненное повтора больше не ждёт. О том, что
// осталось pending, соседям говорит settle: пока попытка идёт,
// повтор из соседней вкладки отправил бы то же сообщение второй
// раз (ADR-035).
settled: messages.filter((m) => m.status !== "pending").map((m) => m.id)
.concat(removed.map((r) => r.id)),
});
}
// announceChats — список чатов изменился без сообщений: прочитан чат,
// заведён или скрыт собеседник.
function announceChats() {
emit("chats");
share({ kind: "chats" });
}
// --- соседние вкладки ---------------------------------------------------
// share отдаёт изменение соседним вкладкам. Канал открыт, только пока
// синхронизация жива: после выхода база стирается, рассылать нечего.
function share(payload) {
state.channel?.postMessage(payload);
}
function openChannel() {
if (!shared || state.channel) {
return;
}
state.channel = new BroadcastChannel(CHANNEL);
state.channel.addEventListener("message", (event) => receive(event.data));
// Вкладка, открытая позже владельца, состояния сети ещё не знает.
share({ kind: "hello" });
}
function closeChannel() {
state.channel?.close();
state.channel = null;
}
// receive применяет чужое изменение: в базу оно уже записано той вкладкой,
// здесь остаётся поднять экраны. Рассылать это дальше нельзя — иначе
// сообщение ходило бы по кругу.
function receive(data) {
if (!state.running || data === null || typeof data !== "object") {
return;
}
switch (data.kind) {
case "hello":
// Отвечает владелец: только он знает, цел ли поток.
if (state.release) {
share({ kind: "net", online: state.online });
}
return;
case "net":
applyOnline(data.online === true);
return;
case "chats":
emit("chats");
return;
case "pending":
// Соседняя вкладка не отправила сообщение и повторять его не будет:
// повторяет владелец потока.
for (const id of data.ids ?? []) {
state.pending.add(id);
}
return;
case "changed":
for (const id of data.settled ?? []) {
state.pending.delete(id);
}
for (const detail of data.details ?? []) {
emit("messages", detail);
}
emit("chats");
return;
default:
}
}
// --- жизненный цикл -----------------------------------------------------
// start поднимает синхронизацию после входа или восстановления сессии.
// Ключи берутся из IndexedDB: наружу они не выходят.
export async function start() {
if (state.running) {
return;
}
let meta;
try {
meta = await db.meta(["nick", "privateKey"]);
} catch {
return;
}
if (!meta.nick || !meta.privateKey) {
return;
}
state.running = true;
state.nick = meta.nick;
state.privateKey = meta.privateKey;
openChannel();
try {
for (const m of await db.pendingMessages()) {
state.pending.add(m.id);
}
} catch {
// Не прочли — повторим при следующем запуске; отправка не сломана.
}
await connect();
}
// stop гасит синхронизацию: выход, удаление аккаунта, истёкшая сессия.
// Базу не трогает — это дело main.js.
export function stop() {
state.running = false;
clearTimer();
if (state.inboxTimer !== null) {
clearTimeout(state.inboxTimer);
state.inboxTimer = null;
}
if (state.close) {
state.close();
state.close = null;
}
// Замок отпускается раньше, чем гаснет всё остальное: соседняя вкладка
// ждёт очереди и займёт поток сразу (ADR-035).
yieldStream();
closeChannel();
state.nick = null;
state.privateKey = null;
state.device = null;
state.keys.clear();
state.pending.clear();
state.inbox.length = 0;
state.wait = RETRY_MIN;
state.hold = RETRY_MIN;
setOnline(false);
}
export function online() {
return state.online;
}
export function nick() {
return state.nick;
}
export function deviceId() {
return state.device;
}
// --- устройство ---------------------------------------------------------
// ensureDevice — deviceId устройства: 16 случайных байт base64url,
// заводится при первом входе и живёт в IndexedDB (ADR-017).
// 409 device_conflict означает, что идентификатор занят другим аккаунтом:
// берём новый.
async function ensureDevice() {
let id = (await db.meta(["deviceId"])).deviceId ?? null;
for (let attempt = 0; attempt < 3; attempt += 1) {
if (!id) {
id = newId();
await db.putMeta({ deviceId: id });
}
try {
await api.registerDevice(id);
return id;
} catch (err) {
if (err instanceof ApiError && err.code === "device_conflict") {
id = null;
continue;
}
throw err;
}
}
throw new Error("не удалось завести устройство");
}
// --- поток событий ------------------------------------------------------
async function connect() {
if (!state.running) {
return;
}
clearTimer();
try {
state.device = await ensureDevice();
} catch (err) {
// 401 unauthenticated уже увёл на экран входа и остановил нас.
if (err instanceof NetworkError) {
// Запрос не дошёл — это и есть «нет соединения» (ADR-028).
setOnline(false);
}
if (transient(err)) {
retryLater();
}
return;
}
if (state.release) {
// Поток уже наш: переподключение идёт под тем же замком.
openStream();
return;
}
claimStream();
}
// claimStream берёт владение потоком. Устройство у вкладок одного профиля
// общее (ADR-017), а соединение на устройство сервер держит одно: без
// арбитража вкладки бесконечно отбирали бы поток друг у друга. Замок
// держится, пока жива синхронизация; ожидающие вкладки живут на
// broadcast от владельца (ADR-035).
function claimStream() {
if (state.claim) {
return;
}
if (!shared) {
openStream();
return;
}
const claim = new AbortController();
state.claim = claim;
navigator.locks.request(STREAM_LOCK, { signal: claim.signal }, () => new Promise((release) => {
state.claim = null;
if (!state.running) {
release();
return;
}
state.release = release;
openStream();
})).catch(() => {
// Ожидание отменено выходом или замок не дался — потока у нас нет.
if (state.claim === claim) {
state.claim = null;
}
});
}
// yieldStream отпускает владение: соседняя вкладка займёт поток сразу.
function yieldStream() {
if (state.claim) {
state.claim.abort();
state.claim = null;
}
if (state.release) {
state.release();
state.release = null;
}
}
function openStream() {
if (state.close) {
state.close();
}
state.close = api.stream(state.device, {
msg: (envelope) => {
if (envelope) {
state.inbox.push(envelope);
schedule();
}
},
ready: () => {
state.wait = RETRY_MIN;
setOnline(true);
serial(afterReady);
},
error: (closed) => {
setOnline(false);
// Браузер переподключается сам, пока поток не закрыт насовсем.
if (closed) {
retryLater();
}
},
});
}
function retryLater() {
if (state.timer !== null || !state.running) {
return;
}
const delay = state.wait;
state.wait = Math.min(delay * 2, RETRY_MAX);
state.timer = setTimeout(() => {
state.timer = null;
recover();
}, delay);
}
function clearTimer() {
if (state.timer !== null) {
clearTimeout(state.timer);
state.timer = null;
}
}
// recover разбирает окончательно закрытый поток. Причин две: сессии
// больше нет — это увидит GET /api/me и уведёт на экран входа; или
// устройства больше нет — тогда его надо завести заново.
async function recover() {
if (!state.running) {
return;
}
try {
await api.me();
} catch (err) {
if (err instanceof NetworkError) {
retryLater();
}
return;
}
await connect();
}
// setOnline — состояние сети этой вкладки. Владелец потока рассказывает
// о нём соседям: своего потока у них нет (ADR-035).
function setOnline(value) {
if (state.online === value) {
return;
}
applyOnline(value);
if (state.release) {
share({ kind: "net", online: value });
}
}
function applyOnline(value) {
if (state.online === value) {
return;
}
state.online = value;
emit("net", { online: value });
}
// --- очередь работ ------------------------------------------------------
// serial выстраивает работу с базой в очередь: приём, отправка и повтор
// не должны идти одновременно.
let chain = Promise.resolve();
function serial(task) {
const next = chain.then(() => task());
chain = next.catch(() => {});
return next;
}
// schedule откладывает разбор входящих на следующий такт: очередь при
// подключении приходит событием на конверт, а записать её и подтвердить
// лучше пачкой. Разбор забирает всё, что успело накопиться.
function schedule() {
if (state.scheduled) {
return;
}
state.scheduled = true;
setTimeout(() => {
state.scheduled = false;
serial(flush);
}, 0);
}
// --- приём --------------------------------------------------------------
async function flush() {
const batch = state.inbox.splice(0, state.inbox.length);
if (batch.length === 0) {
return;
}
const messages = [];
const acked = [];
const kept = [];
for (const envelope of batch) {
if (!usable(envelope)) {
// Разобрать нечего, но и держать это в очереди сервера незачем.
if (typeof envelope?.id === "string") {
acked.push(envelope.id);
}
continue;
}
const record = await decode(envelope);
if (record === null) {
// Ключа сейчас не добыть по причине, которая пройдёт: конверт
// остаётся у нас и разбирается заново. Ждать переподключения
// нельзя — поток цел и рваться не собирается.
kept.push(envelope);
continue;
}
messages.push(record);
acked.push(record.id);
}
if (messages.length > 0) {
await db.saveMessages({ messages, me: state.nick, incoming: true });
notify(messages);
}
if (kept.length > 0) {
state.inbox.unshift(...kept);
postpone();
} else {
state.hold = RETRY_MIN;
}
// ACK — только после успешной записи (docs/storage.md).
await ackAll(acked);
}
// postpone откладывает повторный разбор: причина, по которой конверт не
// разобрался, проходит сама, но сообщать о себе не умеет. Пауза
// удваивается, удачный разбор возвращает её к минимуму.
function postpone() {
if (state.inboxTimer !== null || !state.running) {
return;
}
const delay = state.hold;
state.hold = Math.min(delay * 2, RETRY_MAX);
state.inboxTimer = setTimeout(() => {
state.inboxTimer = null;
schedule();
}, delay);
}
// usable — форма конверта (docs/protocol.md, «Типы»). Сервер её проверяет,
// но запись в базу собирается из этих полей, и мусор до неё не доходит.
function usable(e) {
return e !== null && typeof e === "object"
&& typeof e.id === "string" && validUlid(e.id)
&& typeof e.from === "string"
&& typeof e.keyId === "string"
&& typeof e.iv === "string" && typeof e.ct === "string"
&& Number.isFinite(e.ts)
&& (typeof e.to?.dm === "string") !== (typeof e.to?.room === "string");
}
// decode превращает конверт в запись messages. null означает «сейчас
// не разобрать по причине, которая пройдёт»: конверт остаётся и у нас,
// и в очереди сервера — ACK по нему не уходит. Ошибка AEAD
// и неизвестный keyId причиной не являются —
// сообщение сохраняется нерасшифрованным (docs/crypto.md, «Сообщение»).
async function decode(envelope) {
const me = state.nick;
const peer = envelope.to.dm
? (envelope.from === me ? envelope.to.dm : envelope.from)
: null;
const base = {
id: envelope.id,
chatId: peer === null ? db.roomChatId(envelope.to.room) : db.dmChatId(peer),
from: envelope.from,
text: null,
ts: envelope.ts,
status: "sent",
};
// Комнаты — этап 3: ключа комнаты на устройстве ещё нет.
if (peer === null || envelope.keyId !== DM_KEY_ID) {
return { ...base, undecryptable: "unknown_key", raw: envelope };
}
let key;
try {
key = await chatKey(peer);
} catch (err) {
if (transient(err)) {
return null;
}
// Ник исчез: публичного ключа не будет и позже, но raw остаётся.
return { ...base, undecryptable: "unknown_key", raw: envelope };
}
try {
const text = await openMessage(key, { ...envelope, chat: dmLabel(me, peer) });
return { ...base, text };
} catch {
// Смену ключа собеседника разбирает TOFU (ADR-016) — этап 3;
// до тех пор любая неудача AEAD выглядит одинаково.
return { ...base, undecryptable: "bad_aead", raw: envelope };
}
}
async function ackAll(ids) {
for (let i = 0; i < ids.length; i += api.MAX_ACK) {
try {
await api.ack(state.device, ids.slice(i, i + api.MAX_ACK));
} catch {
// Не подтвердили — сервер выдаст конверты заново, а put по тому же
// id дублей не создаст (ADR-017).
return;
}
}
}
// --- после ready --------------------------------------------------------
// afterReady — очередь выдана целиком. Клиент перечитывает контакты
// и повторяет неотправленное (docs/ui.md, «Сеть и состояния»).
// Комнаты — этап 3.
async function afterReady() {
await refreshContacts();
await retryPending();
}
async function refreshContacts() {
let list;
try {
list = await api.contacts();
} catch {
return;
}
let changed = false;
for (const contact of list) {
await rememberPeer(contact.nick, contact.publicKey, contact.createdAt);
const chatId = db.dmChatId(contact.nick);
if (!(await db.chat(chatId))) {
await db.putChat(db.blankChat(chatId));
changed = true;
}
}
if (changed) {
announceChats();
}
}
// retryPending повторяет неотправленное после подключения. Идёт прямо,
// без serial: afterReady уже внутри очереди.
async function retryPending() {
for (const id of [...state.pending]) {
let record;
try {
record = await db.message(id);
} catch {
return;
}
if (!record || record.status !== "pending") {
state.pending.delete(id);
continue;
}
await attempt(record, record.id);
}
}
// --- собеседники --------------------------------------------------------
// chatKey — ключ личного чата из памяти или выведенный заново.
async function chatKey(peer) {
const cached = state.keys.get(peer);
if (cached) {
return cached;
}
const record = await knownPeer(peer);
const key = await dmKey(state.privateKey, record.publicKey, state.nick, peer);
state.keys.set(peer, key);
return key;
}
// knownPeer — запись TOFU. Ключа нет — берём у сервера и запоминаем
// как есть: сверка изменившегося ключа — этап 3 (ADR-016).
async function knownPeer(nick) {
const known = await db.peer(nick);
if (known) {
return known;
}
const user = await api.user(nick);
return rememberPeer(user.nick, user.publicKey);
}
// rememberPeer запоминает ключ при первом контакте. Уже знакомый ник
// не трогается: смена ключа — состояние, а не перезапись (ADR-016).
async function rememberPeer(nick, publicKey, firstSeen = Date.now()) {
const known = await db.peer(nick);
if (known) {
return known;
}
const record = {
nick,
publicKey,
fingerprint: await fingerprintOf(publicKey),
firstSeen,
pending: null,
};
await db.putPeer(record);
return record;
}
// --- отправка -----------------------------------------------------------
// send — новое исходящее сообщение. Пустая строка не отправляется;
// предел в maxMessageChars держит строка ввода (docs/ui.md, «Чат»).
// Отдаёт id записи или null, если отправлять нечего.
export function send(chatId, text) {
const body = String(text ?? "").trim();
if (!state.running || body === "" || db.peerOf(chatId) === null) {
return Promise.resolve(null);
}
return serial(() => attempt({ chatId, text: body }, null));
}
// retry — повтор с пометки «не отправлено».
export function retry(id) {
if (!state.running) {
return Promise.resolve(null);
}
return serial(async () => {
const record = await db.message(id);
if (!record || record.status === "sent") {
return null;
}
return attempt(record, record.id);
});
}
// REUSE — запас под окно часов сервера: он принимает сообщение, пока время
// в ULID расходится с его часами не больше чем на пять минут (ADR-017).
// Идентификатор переиспользуется, пока до края окна остаётся минута: за неё
// успевают шифрование, очередь работ и сама сеть, так что дошедший запрос
// застаёт окно ещё открытым.
const REUSE = 4 * 60 * 1000;
// attempt — одна попытка отправки. Прежний ULID сохраняется, пока его время
// годится серверу: ответ на POST мог потеряться после того, как сервер
// сообщение принял, и повтор с тем же идентификатором получатель молча
// пропустит (ADR-034), а повтор с новым лёг бы у него вторым сообщением
// (ADR-036). Идентификатор старше запаса заменяется свежим, и тогда старая
// запись удаляется: время в id должно совпадать с временем фактической
// отправки — иначе после долгого офлайна сервер ответит clock_skew.
//
// fresh требует свежий идентификатор, каким бы годным ни выглядел прежний:
// так возвращается попытка, у которой переиспользованный id сервер отверг
// по часам.
async function attempt(source, previousId, fresh = false) {
const peer = db.peerOf(source.chatId);
if (peer === null) {
state.pending.delete(previousId);
return null;
}
const keep = !fresh && previousId !== null && reusable(previousId);
const message = {
id: keep ? previousId : ulid(),
chatId: source.chatId,
from: state.nick,
text: source.text,
// Время показа идёт за идентификатором: сохранённый id оставляет
// и прежнее ts — до 202, которое принесёт серверное.
ts: keep ? source.ts : Date.now(),
status: "pending",
};
const stale = previousId !== null && !keep;
if (stale) {
state.pending.delete(previousId);
}
state.pending.add(message.id);
await db.saveMessages({
messages: [message],
remove: stale ? [previousId] : [],
me: state.nick,
});
notify([message], stale ? [{ chatId: source.chatId, id: previousId }] : []);
const err = await post(message, peer);
if (err === null) {
return message.id;
}
// Возраст переиспользованного id сервер считает по своим часам: к времени,
// проведённому в pending, добавляется расхождение часов. Отставание в пару
// минут выводит за окно идентификатор, который клиенту кажется свежим.
// Это ровно та причина, ради которой id и меняется, — берём свежий и идём
// второй раз. Второго круга нет: fresh снимает переиспользование, и такой
// же отказ на свежем id означает, что часы врут по-настоящему (ADR-036).
if (keep && err instanceof ApiError && err.code === "clock_skew") {
return attempt(message, message.id, true);
}
await settle(message, err);
return message.id;
}
// reusable — годится ли прежний идентификатор для новой попытки. Часы
// сравниваются со своими же: других у клиента нет, и первый ULID берётся
// из них же. Часы, врущие сверх окна, отсекает сервер: clock_skew на
// переиспользованном id разбирает attempt, на свежем — settle.
function reusable(id) {
const ms = ulidTime(id);
return ms !== null && Math.abs(Date.now() - ms) < REUSE;
}
// post шифрует и отдаёт конверт серверу. from в AAD — собственный ник:
// сервер проставит то же значение из сессии, и AAD сойдётся у получателя
// (docs/crypto.md, «Сообщение»).
//
// Отдаёт null при 202 и отказ, если он был: судьбу отказа решает attempt —
// clock_skew на переиспользованном идентификаторе кончается не полосой,
// а второй попыткой.
async function post(message, peer) {
let envelope;
try {
const sealed = await sealMessage(await chatKey(peer), {
id: message.id,
chat: dmLabel(state.nick, peer),
from: state.nick,
keyId: DM_KEY_ID,
text: message.text,
});
envelope = {
id: message.id,
to: { dm: peer },
keyId: DM_KEY_ID,
iv: sealed.iv,
ct: sealed.ct,
};
} catch (err) {
return err;
}
try {
const answer = await api.sendMessage(state.device, envelope);
// Запрос дошёл: сеть есть, что бы ни думал поток событий (ADR-028).
setOnline(true);
state.pending.delete(message.id);
const sent = { ...message, status: "sent", ts: answer?.ts ?? message.ts };
await db.saveMessages({ messages: [sent], me: state.nick });
notify([sent]);
return null;
} catch (err) {
// Ответ с кодом — то же доказательство, что запрос дошёл, что и 202:
// сеть есть, что бы ни думал поток событий (ADR-028). Ошибка шифрования
// сюда не попадает — она случается до запроса. 401 unauthenticated уже
// увёл на экран входа: состояние сети там ничьё.
if (err instanceof ApiError && state.running) {
setOnline(true);
}
return err;
}
}
// settle разбирает отказ. Сеть и 500 сообщение не хоронят: оно остаётся
// pending и повторится при следующем подключении (ADR-027). Удалённое
// устройство чинится тем же способом — переподключением. Остальные 4xx —
// failed с текстом отказа (ADR-033).
async function settle(message, err) {
if (err instanceof NetworkError) {
// Поток событий молчания сети не замечает: у EventSource нет
// таймаута на тишину. Не дошедший запрос — та же полоса «нет
// соединения» (docs/ui.md, «Сеть и состояния», ADR-028).
setOnline(false);
}
if (transient(err)) {
share({ kind: "pending", ids: [message.id] });
return;
}
if (err instanceof ApiError && err.code === "unknown_device") {
share({ kind: "pending", ids: [message.id] });
retryLater();
return;
}
state.pending.delete(message.id);
const failed = { ...message, status: "failed", error: api.errorText(err) };
await db.saveMessages({ messages: [failed], me: state.nick });
notify([failed]);
}
// transient — отказ, который пройдёт сам: запрос не дошёл или сервер
// не справился. Повтор допустим (ADR-027).
function transient(err) {
return err instanceof NetworkError || (err instanceof ApiError && err.status >= 500);
}
// --- действия экранов ---------------------------------------------------
// openDm заводит личный чат с ником и отдаёт chatId. Строку списка
// заводит сервер (ADR-019), публичный ключ приходит тем же ответом.
// Ошибки — 404 unknown_user и 400 self (docs/ui.md, «Новый чат»).
export async function openDm(peer) {
const answer = await api.addContact(peer);
await rememberPeer(answer.nick, answer.publicKey);
const chatId = db.dmChatId(answer.nick);
const existing = await db.chat(chatId);
if (!existing || existing.hidden) {
// hideChat читает и пишет одной транзакцией и заводит недостающую
// запись: приём сообщений идёт своим чередом и в неё не врезается.
await db.hideChat(chatId, false);
announceChats();
}
return chatId;
}
// forgetChat — «убрать из списка» в карточке контакта. Строка на сервере
// уходит, зеркальная у собеседника остаётся: это не блокировка (ADR-019).
// История на устройстве не трогается — чат прячется.
export async function forgetChat(chatId) {
const peer = db.peerOf(chatId);
if (peer !== null) {
await api.removeContact(peer);
}
await db.hideChat(chatId, true);
announceChats();
}
// markRead — чат прочитан. Граница «новых» и счётчик локальные, на сервер
// не уходят (docs/storage.md).
export async function markRead(chatId) {
const record = await db.markRead(chatId);
announceChats();
return record;
}
// Чтение для экранов. Писать в базу им не нужно: всё, что меняет
// состояние, живёт здесь. dmChatId и peerOf — форма ключа чата
// (docs/storage.md): экраны собирают её из ника маршрута, а не из строки.
export { chats, chat, message, messagesBefore, peer, dmChatId, peerOf, PAGE } from "./db.js";