Этап 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
This commit is contained in:
+253
@@ -59,6 +59,20 @@ function value(request) {
|
||||
});
|
||||
}
|
||||
|
||||
// get и put — одна запись одного хранилища. Ключ у chats, messages
|
||||
// и peers лежит внутри значения (keyPath), поэтому put берёт запись целиком.
|
||||
async function get(name, key) {
|
||||
const db = await open();
|
||||
return value(db.transaction(name, "readonly").objectStore(name).get(key));
|
||||
}
|
||||
|
||||
async function put(name, record) {
|
||||
const db = await open();
|
||||
const tx = db.transaction(name, "readwrite");
|
||||
tx.objectStore(name).put(record);
|
||||
await done(tx);
|
||||
}
|
||||
|
||||
// meta читает несколько ключей одной транзакцией.
|
||||
export async function meta(keys) {
|
||||
const db = await open();
|
||||
@@ -81,6 +95,245 @@ export async function putMeta(entries) {
|
||||
await done(tx);
|
||||
}
|
||||
|
||||
// --- чаты ---------------------------------------------------------------
|
||||
|
||||
// PAGE — страница ленты: 50 сообщений (docs/storage.md).
|
||||
export const PAGE = 50;
|
||||
|
||||
const DM = "dm:";
|
||||
const ROOM = "room:";
|
||||
|
||||
// Ключ чата — "dm:<собеседник>" или "room:<roomId>" (docs/storage.md).
|
||||
// Это не метка чата в AAD сообщения: там у личного чата оба ника.
|
||||
export function dmChatId(peer) {
|
||||
return DM + peer;
|
||||
}
|
||||
|
||||
export function roomChatId(roomId) {
|
||||
return ROOM + roomId;
|
||||
}
|
||||
|
||||
// peerOf — с кем личный чат; у комнаты собеседника нет.
|
||||
export function peerOf(chatId) {
|
||||
return chatId.startsWith(DM) ? chatId.slice(DM.length) : null;
|
||||
}
|
||||
|
||||
// blankChat — пустая запись чата по её ключу. title — имя без «@» и «#»:
|
||||
// сигил ставит экран. Комнате имя приходит из GET /api/rooms (этап 3),
|
||||
// до этого вместо имени стоит идентификатор.
|
||||
export function blankChat(id) {
|
||||
const base = { id, title: "", lastId: null, lastReadId: null, unread: 0, hidden: false };
|
||||
const peer = peerOf(id);
|
||||
if (peer !== null) {
|
||||
return { ...base, type: "dm", title: peer, peer };
|
||||
}
|
||||
const roomId = id.slice(ROOM.length);
|
||||
return { ...base, type: "room", title: roomId, roomId };
|
||||
}
|
||||
|
||||
// chats — список чатов в порядке docs/ui.md: по lastId по убыванию.
|
||||
// Скрытые («убрать из списка») не отдаются, пока их не попросят.
|
||||
export async function chats({ hidden = false } = {}) {
|
||||
const db = await open();
|
||||
const store = db.transaction("chats", "readonly").objectStore("chats");
|
||||
const list = await value(store.getAll());
|
||||
return list.filter((c) => hidden || !c.hidden).sort(byLastId);
|
||||
}
|
||||
|
||||
function byLastId(a, b) {
|
||||
if (a.lastId !== b.lastId) {
|
||||
if (!a.lastId) {
|
||||
return 1;
|
||||
}
|
||||
if (!b.lastId) {
|
||||
return -1;
|
||||
}
|
||||
return a.lastId < b.lastId ? 1 : -1;
|
||||
}
|
||||
return a.id < b.id ? -1 : 1;
|
||||
}
|
||||
|
||||
export function chat(id) {
|
||||
return get("chats", id);
|
||||
}
|
||||
|
||||
export function putChat(record) {
|
||||
return put("chats", record);
|
||||
}
|
||||
|
||||
// markRead — чат прочитан: счётчик обнуляется, граница «новых» уезжает
|
||||
// к последнему сообщению. Обе величины локальные, на сервер не уходят
|
||||
// (docs/storage.md).
|
||||
export async function markRead(chatId) {
|
||||
const db = await open();
|
||||
const tx = db.transaction("chats", "readwrite");
|
||||
const store = tx.objectStore("chats");
|
||||
const record = await value(store.get(chatId));
|
||||
if (record) {
|
||||
record.unread = 0;
|
||||
record.lastReadId = record.lastId;
|
||||
store.put(record);
|
||||
}
|
||||
await done(tx);
|
||||
return record ?? null;
|
||||
}
|
||||
|
||||
// hideChat прячет чат из списка или возвращает его туда. История
|
||||
// не трогается: «убрать из списка» — не удаление (ADR-019).
|
||||
export async function hideChat(chatId, hidden) {
|
||||
const db = await open();
|
||||
const tx = db.transaction("chats", "readwrite");
|
||||
const store = tx.objectStore("chats");
|
||||
const record = (await value(store.get(chatId))) ?? blankChat(chatId);
|
||||
record.hidden = hidden;
|
||||
store.put(record);
|
||||
await done(tx);
|
||||
return record;
|
||||
}
|
||||
|
||||
// --- сообщения ----------------------------------------------------------
|
||||
|
||||
export function message(id) {
|
||||
return get("messages", id);
|
||||
}
|
||||
|
||||
// saveMessages пишет сообщения и обновляет их чаты одной транзакцией.
|
||||
// ACK серверу уходит только после успешной записи (docs/storage.md),
|
||||
// поэтому лента и счётчик непрочитанных не должны расходиться.
|
||||
//
|
||||
// remove — идентификаторы, которые надо убрать: устаревший ULID
|
||||
// неотправленного сообщения меняется на свежий, и старая запись уходит
|
||||
// (ADR-036).
|
||||
// me — собственный ник: свои сообщения непрочитанными не считаются.
|
||||
// incoming — сообщения пришли из потока событий: известный id
|
||||
// игнорируется целиком, перезаписи нет (ADR-034).
|
||||
//
|
||||
// Отдаёт ключи затронутых чатов.
|
||||
export async function saveMessages({
|
||||
messages = [],
|
||||
remove = [],
|
||||
me = null,
|
||||
incoming = false,
|
||||
} = {}) {
|
||||
if (messages.length === 0 && remove.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const db = await open();
|
||||
const tx = db.transaction(["messages", "chats"], "readwrite");
|
||||
const store = tx.objectStore("messages");
|
||||
const chatStore = tx.objectStore("chats");
|
||||
|
||||
for (const id of remove) {
|
||||
store.delete(id);
|
||||
}
|
||||
// Оба чтения — запросы этой же транзакции: она живёт, пока их ждут.
|
||||
const known = await Promise.all(messages.map((m) => value(store.get(m.id))));
|
||||
const ids = [...new Set(messages.map((m) => m.chatId))];
|
||||
const records = await Promise.all(ids.map((id) => value(chatStore.get(id))));
|
||||
|
||||
const touched = new Map();
|
||||
ids.forEach((id, i) => touched.set(id, records[i] ?? blankChat(id)));
|
||||
|
||||
// Повтор доставки не должен ни дублировать ленту, ни двигать счётчик:
|
||||
// сервер выдаёт очередь заново при каждом подключении и вправе
|
||||
// прислать конверт дважды в одной пачке (ADR-017). Дубли внутри пачки
|
||||
// видны только здесь: known собран до первого put.
|
||||
const seen = new Set();
|
||||
messages.forEach((m, i) => {
|
||||
const twice = seen.has(m.id);
|
||||
seen.add(m.id);
|
||||
// Входящее с уже известным id игнорируется целиком: id открыт
|
||||
// в конверте, и перезапись отдала бы собеседнику чужую запись
|
||||
// в истории (ADR-034). Исходящее по своему id пишется всегда —
|
||||
// это переход pending → sent/failed.
|
||||
if (twice || (incoming && known[i] !== undefined)) {
|
||||
return;
|
||||
}
|
||||
store.put(m);
|
||||
const record = touched.get(m.chatId);
|
||||
if (!record.lastId || record.lastId < m.id) {
|
||||
record.lastId = m.id;
|
||||
}
|
||||
if (known[i] !== undefined) {
|
||||
return;
|
||||
}
|
||||
if (m.from !== me && (!record.lastReadId || record.lastReadId < m.id)) {
|
||||
record.unread += 1;
|
||||
}
|
||||
// Новое сообщение возвращает скрытый чат в список.
|
||||
record.hidden = false;
|
||||
});
|
||||
|
||||
for (const record of touched.values()) {
|
||||
chatStore.put(record);
|
||||
}
|
||||
await done(tx);
|
||||
return [...touched.keys()];
|
||||
}
|
||||
|
||||
// messagesBefore — страница ленты назад от before, не включая его,
|
||||
// по индексу "chat" (docs/storage.md). Отдаёт по возрастанию id.
|
||||
export async function messagesBefore(chatId, before = null, limit = PAGE) {
|
||||
const db = await open();
|
||||
const store = db.transaction("messages", "readonly").objectStore("messages");
|
||||
// Ключ индекса — [chatId, id]. Массив больше любой строки, поэтому
|
||||
// [chatId, []] — верхняя граница всех сообщений чата, а [chatId] —
|
||||
// нижняя: короткий массив идёт раньше своих продолжений.
|
||||
const range = before
|
||||
? IDBKeyRange.bound([chatId], [chatId, before], false, true)
|
||||
: IDBKeyRange.bound([chatId], [chatId, []]);
|
||||
const out = [];
|
||||
await cursor(store.index("chat").openCursor(range, "prev"), (record) => {
|
||||
out.push(record);
|
||||
return out.length < limit;
|
||||
});
|
||||
out.reverse();
|
||||
return out;
|
||||
}
|
||||
|
||||
// pendingMessages — неотправленное по возрастанию id. Индекса по статусу
|
||||
// в схеме нет (docs/storage.md), поэтому это проход курсором: он делается
|
||||
// один раз при старте, дальше отправитель ведёт свой список.
|
||||
export async function pendingMessages() {
|
||||
const db = await open();
|
||||
const store = db.transaction("messages", "readonly").objectStore("messages");
|
||||
const out = [];
|
||||
await cursor(store.openCursor(), (record) => {
|
||||
if (record.status === "pending") {
|
||||
out.push(record);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// cursor обходит курсор, пока step не скажет «хватит».
|
||||
function cursor(request, step) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => {
|
||||
const current = request.result;
|
||||
if (!current || !step(current.value)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
current.continue();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
// --- собеседники --------------------------------------------------------
|
||||
|
||||
// peers — доверие к ключам, TOFU (ADR-016). Запись заводится при первом
|
||||
// получении ключа; сверка изменившегося ключа и pending — этап 3.
|
||||
export function peer(nick) {
|
||||
return get("peers", nick);
|
||||
}
|
||||
|
||||
export function putPeer(record) {
|
||||
return put("peers", record);
|
||||
}
|
||||
|
||||
// persist просит браузер не вычищать базу: история на устройстве —
|
||||
// единственная копия (docs/storage.md).
|
||||
export async function persist() {
|
||||
|
||||
Reference in New Issue
Block a user