Этап 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:
2026-08-22 18:18:04 +03:00
co-authored by Claude Opus 5
parent 597c55301c
commit 63a7a1ef52
39 changed files with 5126 additions and 46 deletions
+97 -5
View File
@@ -1,6 +1,10 @@
// Обёртки над fetch. Форма запросов и ответов — docs/protocol.md:
// JSON в обе стороны, cookie сессии, ошибка — {error, message}.
// SSE и ACK появятся на этапе 2.
// Обёртки над 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 {
@@ -30,6 +34,9 @@ const TEXT = {
invite_required: "нужен инвайт-код",
invalid_invite: "инвайт-код не подходит",
rate_limited: "слишком часто, попробуйте позже",
unknown_user: "такого ника нет",
self: "нельзя писать себе",
clock_skew: "проверьте часы на устройстве: расхождение больше 5 минут",
};
export function errorText(err) {
@@ -53,12 +60,21 @@ export function onSessionExpired(handler) {
// quiet: не звать expired() на 401 unauthenticated. Нужно ровно там, где
// «сессии нет» — не конец сеанса, а ожидаемый ответ (dropSession).
async function request(method, path, body, { quiet = false } = {}) {
// 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) {
init.headers = { "Content-Type": "application/json" };
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);
@@ -128,3 +144,79 @@ export function 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)}`);
}
// --- контакты ----------------------------------------------------------
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)}`);
}
// --- сообщения ---------------------------------------------------------
// 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)));
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;
}
}
+94
View File
@@ -13,11 +13,21 @@ const SALT_PREFIX = "bare-v1:";
const INFO_AUTH = "bare-auth-v1";
const INFO_KEK = "bare-kek-v1";
const BLOB_AAD = "bare-blob-v1|";
const DM_SALT = "bare-dm-v1";
const MSG_AAD = "bare-msg-v1|";
// DM_KEY_ID — keyId личного чата: ключ выводится из ECDH, отдельного
// идентификатора у него нет (docs/crypto.md, «Сообщение»).
export const DM_KEY_ID = "dm";
// Длина секрета аккаунта (ADR-014) и вектора инициализации AES-GCM.
export const SECRET_LEN = 32;
const IV_LEN = 12;
// ID_LEN — deviceId, keyId и roomId устроены одинаково: 16 случайных
// байт base64url, 22 символа (docs/crypto.md, «Идентификаторы»).
const ID_LEN = 16;
// Границы числа итераций PBKDF2 (ADR-013, ADR-030). Число приходит от
// сервера — в ответе /api/kdf, /api/config или полем iter в блобе, — а
// считает по нему клиент, поэтому проверить его может только он.
@@ -47,6 +57,11 @@ export function random(length) {
return bytes;
}
// newId — идентификатор устройства, ключа комнаты или комнаты.
export function newId() {
return b64url(random(ID_LEN));
}
export function b64url(input) {
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
let binary = "";
@@ -240,3 +255,82 @@ export async function openBlob(blob, kek, nick) {
}
return { priv: parsed.priv, secret };
}
// --- чат 1:1 -----------------------------------------------------------
// order — ники пары по возрастанию. Сравниваются кодовые единицы, а не
// буквы языка: ник — это [a-z0-9_], и порядок обязан совпасть у обеих
// сторон побайтно (docs/crypto.md, «Чат 1:1»).
export function order(a, b) {
return a < b ? [a, b] : [b, a];
}
// dmLabel — метка чата для AAD сообщения: "dm:" + a + ":" + b.
// Это не ключ хранилища chats: там чат зовётся "dm:<собеседник>".
export function dmLabel(a, b) {
const [first, second] = order(a, b);
return `dm:${first}:${second}`;
}
// dmKey выводит ключ личного чата (docs/crypto.md, «Чат 1:1»).
// Ключ симметричен для обеих сторон и всех их устройств; в IndexedDB
// не пишется — выводится заново из peers.
export async function dmKey(privateKey, peerPublicJwk, me, peer) {
const [a, b] = order(me, peer);
const publicKey = await importPublic(peerPublicJwk);
const shared = await subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, 256);
const material = await subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]);
wipe(new Uint8Array(shared));
return subtle.deriveKey(
{ name: "HKDF", hash: "SHA-256", salt: utf8(DM_SALT), info: utf8(`${a}\0${b}`) },
material,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
// --- сообщение ---------------------------------------------------------
// messageAad привязывает открытые поля конверта к шифротексту: подмена
// любого из них ломает расшифровку (docs/crypto.md, «Сообщение»).
function messageAad({ id, chat, from, keyId }) {
return utf8(`${MSG_AAD}${id}|${chat}|${from}|${keyId}`);
}
// sealMessage шифрует текст сообщения. plain — JSON {"t": текст};
// ничего кроме текста внутрь не кладётся.
export async function sealMessage(key, { id, chat, from, keyId, text }) {
const iv = random(IV_LEN);
const plain = utf8(JSON.stringify({ t: text }));
const ct = await subtle.encrypt(
{ name: "AES-GCM", iv, additionalData: messageAad({ id, chat, from, keyId }) },
key,
plain,
);
wipe(plain);
return { iv: b64url(iv), ct: b64url(ct) };
}
// openMessage расшифровывает конверт и отдаёт текст. Бросает при любой
// порче: не тот ключ, изменившиеся открытые поля, битый base64url.
// Для вызывающего это не фатально — сообщение сохраняется нерасшифрованным
// с кодом причины (docs/storage.md).
export async function openMessage(key, { id, chat, from, keyId, iv, ct }) {
const nonce = unb64url(iv);
if (nonce.length !== IV_LEN) {
throw new Error("iv — не 12 байт");
}
const plain = await subtle.decrypt(
{ name: "AES-GCM", iv: nonce, additionalData: messageAad({ id, chat, from, keyId }) },
key,
unb64url(ct),
);
const bytes = new Uint8Array(plain);
const parsed = JSON.parse(decoder.decode(bytes));
wipe(bytes);
if (parsed === null || typeof parsed !== "object" || typeof parsed.t !== "string") {
throw new Error("в сообщении нет текста");
}
return parsed.t;
}
+253
View File
@@ -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() {
+106 -19
View File
@@ -6,6 +6,7 @@
import * as api from "./api.js";
import * as db from "./db.js";
import * as sync from "./sync.js";
import {
deriveAccountKeys,
exportPrivateJwk,
@@ -21,8 +22,11 @@ import {
validIterations,
wipe,
} from "./crypto.js";
import { clear } from "./ui/dom.js";
import { DESKTOP, clear, wide } from "./ui/dom.js";
import { renderAuth } from "./ui/auth.js";
import { renderChat } from "./ui/chat.js";
import { renderContact } from "./ui/contact.js";
import { renderNew } from "./ui/new.js";
import { renderSettings } from "./ui/settings.js";
import { frame } from "./ui/shell.js";
@@ -32,7 +36,7 @@ const MIN_PASSWORD = 12;
// Ник — ADR-019. Клиент проверяет ту же форму, что и сервер.
const NICK = /^[a-z0-9_]{2,32}$/;
const state = { config: null, me: null };
const state = { config: null, me: null, dispose: null, paint: 0, shown: null };
// AccountError — то, что случилось с ключевым материалом, а не с сетью.
// Сообщение уже пригодно для показа человеку (ADR-028).
@@ -65,22 +69,83 @@ const ctx = {
// --- роутинг -----------------------------------------------------------
// render рисует экран под текущий hash. Маршруты — docs/ui.md, «Каркас»;
// на этом этапе есть только список и настройки, остальные ведут в пустой
// список: чатов, контактов и комнат ещё нет.
function render() {
// route разбирает hash. Маршруты — docs/ui.md, «Каркас»; комнаты придут
// на этапе 3, до тех пор `#/room/…` — неизвестный путь и ведёт в список.
const NICK_ROUTE = /^#\/(dm|contact)\/([a-z0-9_]{2,32})$/;
function route() {
const hash = location.hash || "#/";
if (hash === "#/settings") {
return { kind: "settings" };
}
if (hash === "#/new") {
return { kind: "new" };
}
const nick = NICK_ROUTE.exec(hash);
if (nick) {
return { kind: nick[1], nick: nick[2] };
}
return { kind: "root" };
}
// render рисует экран под текущий hash. Перерисовка гасит подписки
// прежнего экрана: список чатов и лента слушают sync.
async function render() {
const mine = ++state.paint;
const app = document.getElementById("app");
clear(app);
if (!state.me) {
release();
clear(app);
renderAuth(app, ctx);
return;
}
const settings = (location.hash || "#/") === "#/settings";
const { root, main } = frame(ctx, settings ? "screen" : "list");
if (settings) {
renderSettings(main, ctx);
const where = route();
// На десктопе `#/` показывает первый чат — тот, что вверху списка.
let chatId = where.kind === "dm" ? sync.dmChatId(where.nick) : null;
if (where.kind === "root" && wide()) {
const list = await sync.chats().catch(() => []);
if (mine !== state.paint) {
return;
}
chatId = list.length > 0 ? list[0].id : null;
}
release();
clear(app);
state.shown = chatId;
const { root, main, dispose } = frame(ctx, where.kind === "root" ? "list" : "screen", chatId);
// Сначала в документ, потом содержимое: экраны ставят фокус и мотают
// ленту, а на неприсоединённом узле это не работает.
app.append(root);
const parts = [dispose];
if (where.kind === "settings") {
renderSettings(main, ctx);
} else if (where.kind === "new") {
renderNew(main, ctx);
} else if (where.kind === "contact") {
renderContact(main, ctx, where.nick);
} else if (chatId !== null) {
parts.push(renderChat(main, ctx, chatId));
}
state.dispose = () => parts.forEach((off) => off());
}
function release() {
if (state.dispose) {
state.dispose();
state.dispose = null;
}
state.shown = null;
}
// Первый чат на десктопе показывается и тогда, когда список приехал позже
// экрана: после входа на новом устройстве чаты приходят с контактами, уже
// после первой отрисовки. Открытый чат при этом не трогаем — иначе новое
// сообщение в соседнем чате уводило бы из текущего.
function fill() {
if (state.me && state.shown === null && route().kind === "root" && wide()) {
render();
}
}
function go(hash) {
@@ -234,6 +299,13 @@ async function adopt(nick, priv, secret) {
accountSecret: await importSecret(secret),
});
state.me = { nick, publicKey, fingerprint };
connect();
}
// connect поднимает поток событий и синхронизацию. Отказы разбирает сам
// sync: экран входа их уже не касается.
function connect() {
sync.start().catch(() => {});
}
// raise — автоматическое повышение итераций сразу после входа, молча
@@ -303,6 +375,7 @@ async function signOut() {
// forget уносит историю: она на этом устройстве единственная копия
// (docs/storage.md, docs/ui.md).
async function forget() {
sync.stop();
await db.destroy();
state.me = null;
}
@@ -318,6 +391,25 @@ function errorText(err) {
async function boot() {
db.persist();
// Обработчик ставится раньше первого запроса: 401 unauthenticated
// на любом из них — на экран входа, IndexedDB цела.
api.onSessionExpired(() => {
if (state.me) {
sync.stop();
state.me = null;
render();
}
});
addEventListener("hashchange", render);
// Перелом ширины меняет только выбор маршрута: на десктопе `#/` — первый
// чат, на мобильном — список. Открытый экран не трогаем: в нём набранный
// текст, а всё остальное разбирает CSS.
matchMedia(DESKTOP).addEventListener("change", () => {
if (route().kind === "root") {
render();
}
});
sync.on("chats", fill);
try {
await ensureConfig();
} catch {
@@ -325,14 +417,9 @@ async function boot() {
}
state.me = await restore();
render();
addEventListener("hashchange", render);
// 401 unauthenticated на любом запросе — на экран входа, IndexedDB цела.
api.onSessionExpired(() => {
if (state.me) {
state.me = null;
render();
}
});
if (state.me) {
connect();
}
}
boot();
+911
View File
@@ -0,0 +1,911 @@
// Транспорт и данные чата: устройство, поток событий, приём и отправка.
// Экраны берут отсюда данные и сюда же отдают действия; в 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";
+425
View File
@@ -0,0 +1,425 @@
// Экран чата — docs/ui.md, «Чат»; вид — docs/identity/screens.html.
//
// Данные и действия идут только через sync.js: экран не пишет в базу
// и не ходит в сеть сам.
import * as sync from "../sync.js";
import { DESKTOP, clear, el, wide } from "./dom.js";
// Предел текста и порог счётчика — docs/ui.md, «Чат».
const LIMIT = 4000;
const COUNTER_AT = 3500;
// Разделители дат: на десктопе — полная дата, на мобильном — короткая,
// как в эталоне. Время — ЧЧ:ММ в локальной зоне.
const DAY_LONG = new Intl.DateTimeFormat("ru-RU", { weekday: "long", day: "numeric", month: "long" });
const DAY_SHORT = new Intl.DateTimeFormat("ru-RU", { day: "numeric", month: "short" });
const TIME = new Intl.DateTimeFormat("ru-RU", { hour: "2-digit", minute: "2-digit" });
// Тексты нерасшифрованного — docs/ui.md, «Чат». Ключа комнаты нет —
// это про комнату; всё остальное в личном чате означает чужой ключ.
const NO_ROOM_KEY = "не удалось расшифровать: нет ключа комнаты";
const KEY_CHANGED = "не удалось расшифровать: ключ изменился";
// Насколько далеко от низа ленты человек ещё считается «внизу»: пришедшее
// сообщение подматывает ленту только тогда, когда он и так смотрит конец.
const NEAR_BOTTOM = 80;
// renderChat рисует чат в root и отдаёт отписку.
export function renderChat(root, ctx, chatId) {
const view = {
ctx,
chatId,
me: ctx.me.nick,
peer: sync.peerOf(chatId),
limit: ctx.config?.maxMessageChars ?? LIMIT,
alive: true,
// Лента: записи по возрастанию id и их строки в разметке.
items: [],
nodes: new Map(),
// Граница «новых»: первый непрочитанный на момент открытия.
newId: null,
chain: Promise.resolve(),
};
root.append(head(view));
view.feed = el("div", "feed");
view.body = el("div", "grid");
view.body.setAttribute("aria-live", "polite");
view.feed.append(view.body);
root.append(view.feed);
root.append(composer(view));
const offMessages = sync.on("messages", (detail) => {
if (detail.chatId === view.chatId) {
run(view, () => apply(view, detail));
}
});
const offNet = sync.on("net", () => paintBar(view));
const media = matchMedia(DESKTOP);
const onMedia = () => paint(view, true);
media.addEventListener("change", onMedia);
run(view, () => load(view));
return () => {
view.alive = false;
offMessages();
offNet();
media.removeEventListener("change", onMedia);
};
}
// run выстраивает работу экрана в очередь: загрузка и приходящие события
// не должны перемешиваться.
function run(view, task) {
view.chain = view.chain.then(task).catch(() => {});
return view.chain;
}
// --- разметка -----------------------------------------------------------
// head — шапка: имя чата, по нажатию — карточка контакта. «назад» слева
// нужен там, где виден один экран за раз; на десктопе его прячет CSS.
function head(view) {
const bar = el("div", "head");
const back = el("button", "back back--chat", "назад");
back.type = "button";
back.addEventListener("click", () => view.ctx.go("#/"));
const title = el("button", "chat-title", `@${view.peer}`);
title.type = "button";
title.addEventListener("click", () => view.ctx.go(`#/contact/${view.peer}`));
bar.append(back, title);
return bar;
}
// composer — полоса состояния и строка ввода: рамка 1 px ink, слева «>»
// цветом mark. Enter отправляет только на десктопе; на мобильном он делает
// перенос, а отправляет кнопка «>» справа (docs/ui.md, «Чат»).
function composer(view) {
const form = el("form", "compose");
form.noValidate = true;
view.bar = el("p", "bar");
view.bar.hidden = true;
view.bar.setAttribute("aria-live", "polite");
const row = el("div", "input");
const prompt = el("span", "p", ">");
prompt.setAttribute("aria-hidden", "true");
view.field = el("textarea", "input__field");
view.field.rows = 1;
view.field.placeholder = "сообщение";
view.field.maxLength = view.limit;
view.counter = el("span", "counter");
view.counter.hidden = true;
const send = el("button", "input__send", ">");
send.type = "submit";
row.append(prompt, view.field, view.counter, el("span", "enter", "enter — отправить"), send);
form.append(view.bar, row);
view.field.addEventListener("input", () => count(view));
view.field.addEventListener("keydown", (event) => {
if (event.key !== "Enter" || event.shiftKey || event.isComposing) {
return;
}
if (!wide()) {
return;
}
event.preventDefault();
submit(view);
});
form.addEventListener("submit", (event) => {
event.preventDefault();
submit(view);
});
return form;
}
// count — счётчик остатка: появляется после порога (docs/ui.md, «Чат»).
function count(view) {
const length = view.field.value.length;
view.counter.textContent = String(view.limit - length);
view.counter.hidden = length <= COUNTER_AT;
}
function submit(view) {
const text = view.field.value;
if (text.trim() === "") {
return;
}
view.field.value = "";
count(view);
run(view, () => sync.send(view.chatId, text));
}
// --- лента --------------------------------------------------------------
async function load(view) {
let record = null;
let list = [];
try {
record = await sync.chat(view.chatId);
list = await sync.messagesBefore(view.chatId);
} catch {
// Базы нет — рисуем пустую ленту: отправка от этого не ломается.
}
if (!view.alive) {
return;
}
view.items = list;
view.newId = firstUnread(record, list, view.me);
paint(view, true);
// Фокус в строку ввода при открытии чата на десктопе (docs/ui.md,
// «Доступность»); на мобильном это подняло бы клавиатуру на весь экран.
if (wide()) {
view.field.focus();
}
await read(view);
}
// firstUnread — граница «новых»: первый чужой непрочитанный. Своё
// непрочитанным не бывает, поэтому и границей не становится.
function firstUnread(record, list, me) {
if (!record || record.unread <= 0) {
return null;
}
const bound = record.lastReadId;
const found = list.find((m) => m.from !== me && (!bound || m.id > bound));
return found ? found.id : null;
}
// read помечает чат прочитанным — после отрисовки: до этого lastReadId
// и есть граница «новых» (docs/storage.md).
async function read(view) {
try {
await sync.markRead(view.chatId);
} catch {
// Счётчик непрочитанных подождёт до следующего раза.
}
}
// apply разбирает изменения ленты. Дописать в конец дешевле, чем
// перерисовать: лента — живая область, и перерисовка заставила бы
// экранного диктора зачитать её целиком.
async function apply(view, detail) {
const incoming = [];
for (const id of detail.ids ?? []) {
let record = null;
try {
record = await sync.message(id);
} catch {
return;
}
if (record && record.chatId === view.chatId) {
incoming.push(record);
}
}
if (!view.alive) {
return;
}
const bottom = atBottom(view);
let whole = false;
let added = 0;
for (const id of detail.removed ?? []) {
const at = view.items.findIndex((m) => m.id === id);
if (at >= 0) {
view.items.splice(at, 1);
whole = true;
}
}
incoming.sort((a, b) => (a.id < b.id ? -1 : 1));
for (const record of incoming) {
const at = view.items.findIndex((m) => m.id === record.id);
if (at >= 0) {
// Та же запись в новом состоянии: pending стал sent или failed.
view.items[at] = record;
if (!whole) {
redraw(view, record);
}
continue;
}
const last = view.items[view.items.length - 1];
if (last && last.id > record.id) {
// Из очереди сервера пришло то, что старше уже нарисованного.
view.items.splice(view.items.findIndex((m) => m.id > record.id), 0, record);
whole = true;
continue;
}
view.items.push(record);
if (!whole) {
line(view, record, view.items[view.items.length - 2] ?? null);
}
added += 1;
}
if (whole) {
paint(view, bottom);
} else {
if (bottom && added > 0) {
down(view);
}
paintBar(view);
}
if (whole || added > 0) {
await read(view);
}
}
// paint рисует ленту заново.
function paint(view, bottom) {
clear(view.body);
view.nodes.clear();
let previous = null;
for (const record of view.items) {
line(view, record, previous);
previous = record;
}
paintBar(view);
if (bottom) {
down(view);
}
}
// line дописывает сообщение в конец ленты вместе с разделителями,
// которые перед ним нужны.
function line(view, record, previous) {
const day = !previous || dayOf(previous.ts) !== dayOf(record.ts);
if (day) {
view.body.append(divider(label(record.ts), false));
}
const fresh = record.id === view.newId;
if (fresh) {
view.body.append(divider("новые", true));
}
// Подряд идущие сообщения одного автора — без повтора автора.
const first = day || fresh || !previous || previous.from !== record.from;
const node = el("div", first ? "line is-head" : "line");
node.append(author(view, record, first), text(view, record));
view.body.append(node);
view.nodes.set(record.id, node);
}
// redraw обновляет одну строку на месте: автор и группировка от состояния
// сообщения не зависят.
function redraw(view, record) {
const node = view.nodes.get(record.id);
if (!node) {
return;
}
const first = node.classList.contains("is-head");
clear(node);
node.append(author(view, record, first), text(view, record));
}
function divider(caption, fresh) {
const node = el("div", fresh ? "divider divider--new" : "divider");
node.append(el("span", null, caption));
return node;
}
// author — колонка автора: ник и время. Свой ник — цветом mark.
function author(view, record, first) {
const node = el("div", record.from === view.me ? "author author--me" : "author");
if (!first) {
return node;
}
node.append(el("span", null, record.from), el("span", "t", TIME.format(record.ts)));
return node;
}
// text — само сообщение. Нерасшифрованное — курсивом с причиной, pending —
// цветом stone, failed — с пометкой «не отправлено · повторить».
function text(view, record) {
const node = el("div", "text");
if (record.text === null) {
node.classList.add("text--none");
node.textContent = view.peer === null && record.undecryptable === "unknown_key"
? NO_ROOM_KEY
: KEY_CHANGED;
return node;
}
if (record.status === "pending") {
node.classList.add("text--pending");
}
node.append(el("p", "text__body", record.text));
if (record.status === "failed") {
const note = el("p", "fail");
const again = el("button", "link", "повторить");
again.type = "button";
again.addEventListener("click", () => run(view, () => sync.retry(record.id)));
note.append(el("span", null, "не отправлено ·"), again);
node.append(note);
}
return node;
}
// paintBar — полоса над вводом. Причина одна за раз: отказ отправки
// перебивает «нет соединения», потому что он про конкретное сообщение
// и уходит при следующей попытке (ADR-033).
function paintBar(view) {
const failed = lastFailed(view);
if (failed) {
view.bar.className = "bar bar--mark";
view.bar.textContent = failed.error;
view.bar.hidden = false;
return;
}
if (!sync.online()) {
view.bar.className = "bar";
view.bar.textContent = "нет соединения";
view.bar.hidden = false;
return;
}
view.bar.hidden = true;
view.bar.textContent = "";
}
// lastFailed — последнее своё неотправленное сообщение с текстом отказа
// (docs/ui.md, «Чат»). Смотреть на состояние последнего своего нельзя:
// лента отсортирована по ULID, а время в нём — часы отправителя. Отставшие
// часы ставят новое сообщение перед его же старыми, и последним своим
// остаётся давно отправленное — ровно в том случае, ради которого текст
// про часы и заведён (ADR-033).
function lastFailed(view) {
for (let i = view.items.length - 1; i >= 0; i -= 1) {
const record = view.items[i];
if (record.from === view.me && record.status === "failed" && record.error) {
return record;
}
}
return null;
}
// --- прокрутка и даты ---------------------------------------------------
function atBottom(view) {
const feed = view.feed;
return feed.scrollHeight - feed.scrollTop - feed.clientHeight < NEAR_BOTTOM;
}
function down(view) {
view.feed.scrollTop = view.feed.scrollHeight;
}
function dayOf(ts) {
const date = new Date(ts);
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
}
// label — дата разделителя. Короткая форма на мобильном без точки
// сокращения: так в эталоне.
function label(ts) {
if (wide()) {
return DAY_LONG.format(ts);
}
return DAY_SHORT.format(ts).replace(/\.$/, "");
}
+73
View File
@@ -0,0 +1,73 @@
// Список чатов в сайдбаре — docs/ui.md, «Список чатов».
//
// Секции «каналы» и «личные», порядок — по lastId по убыванию (его держит
// sync.chats). Пустая секция не рисуется: комнат до этапа 3 нет.
import * as sync from "../sync.js";
import { clear, el } from "./dom.js";
const SECTIONS = [
["room", "каналы"],
["dm", "личные"],
];
// mount рисует список в root и держит его в актуальном виде, пока экран
// жив. Отдаёт отписку.
export function mount(root, ctx, active) {
// Событий «chats» приходит больше одного подряд; рисует последнее.
let generation = 0;
const paint = async () => {
const mine = ++generation;
let list;
try {
list = await sync.chats();
} catch {
return;
}
if (mine !== generation) {
return;
}
clear(root);
for (const [type, title] of SECTIONS) {
const part = list.filter((chat) => chat.type === type);
if (part.length === 0) {
continue;
}
const items = el("ul", "items");
for (const chat of part) {
items.append(item(ctx, chat, active));
}
root.append(el("h2", "section", title), items);
}
};
const off = sync.on("chats", paint);
paint();
return off;
}
function item(ctx, chat, active) {
const row = el("li");
const button = el("button", "item");
button.type = "button";
if (chat.id === active) {
// Активный чат — инверсия (docs/identity/brief.md).
button.classList.add("is-active");
button.setAttribute("aria-current", "true");
}
button.append(el("span", "item__name", sigil(chat) + chat.title));
if (chat.unread > 0) {
button.append(el("span", "n", String(chat.unread)));
}
button.addEventListener("click", () => ctx.go(hashOf(chat)));
row.append(button);
return row;
}
// Сигил ставит экран: в базе чат зовётся без «@» и «#» (docs/storage.md).
function sigil(chat) {
return chat.type === "dm" ? "@" : "#";
}
function hashOf(chat) {
return chat.type === "dm" ? `#/dm/${chat.peer}` : `#/room/${chat.roomId}`;
}
+61
View File
@@ -0,0 +1,61 @@
// Карточка контакта — docs/ui.md, «Карточка контакта».
//
// Смена ключа собеседника и «доверять новому ключу» появятся вместе
// с TOFU (этап 3, ADR-016): до тех пор у записи peers нет pending.
import * as sync from "../sync.js";
import { fingerprintGroups } from "../crypto.js";
import { button, el, message, setError, setNote } from "./dom.js";
export function renderContact(root, ctx, nick) {
root.append(head(ctx, nick));
const body = el("div", "body settings");
root.append(body);
const card = el("section", "block block--first");
body.append(card, remove(ctx, nick));
// Отпечаток лежит в записи TOFU; её может ещё не быть, если чат
// открыли до первого ключа.
sync.peer(nick).then((record) => {
if (!record?.fingerprint || !card.isConnected) {
return;
}
const groups = fingerprintGroups(record.fingerprint);
card.append(
el("p", "fp", groups.slice(0, 8).join(" ")),
el("p", "fp", groups.slice(8).join(" ")),
el("p", "fp-hint", "сверьте с собеседником голосом или лично"),
);
}).catch(() => {});
}
function head(ctx, nick) {
const bar = el("div", "head");
const back = el("button", "back", "назад");
back.type = "button";
back.addEventListener("click", () => ctx.go(`#/dm/${nick}`));
bar.append(back, el("span", "title", `@${nick}`));
return bar;
}
// remove — «убрать из списка»: строка контакта уходит с сервера, история
// на устройстве остаётся (ADR-019).
function remove(ctx, nick) {
const box = el("section", "block");
const note = message();
const drop = button("убрать из списка");
drop.addEventListener("click", async () => {
drop.disabled = true;
setNote(note, "");
try {
await sync.forgetChat(sync.dmChatId(nick));
ctx.go("#/");
} catch (err) {
setError(note, ctx.errorText(err));
drop.disabled = false;
}
});
box.append(drop, note);
return box;
}
+9
View File
@@ -3,6 +3,15 @@
const SVG = "http://www.w3.org/2000/svg";
// DESKTOP — порог десктопа: сайдбар и чат рядом, один экран за раз кончается
// (docs/ui.md, «Каркас»). Экраны спрашивают ширину в момент события, а не
// перерисовываются на каждое изменение размера.
export const DESKTOP = "(min-width: 760px)";
export function wide() {
return matchMedia(DESKTOP).matches;
}
export function el(tag, className, text) {
const node = document.createElement(tag);
if (className) {
+68
View File
@@ -0,0 +1,68 @@
// Новый чат — docs/ui.md, «Новый чат». Строка `#имя комнаты` появится
// вместе с комнатами (этап 3): создавать пока нечего.
import * as sync from "../sync.js";
import { el, message, setError, setNote } from "./dom.js";
export function renderNew(root, ctx) {
root.append(head(ctx));
const body = el("div", "body");
const form = el("form", "form");
form.noValidate = true;
const row = el("div", "input");
const prompt = el("span", "p", ">");
prompt.setAttribute("aria-hidden", "true");
const field = el("input", "input__field");
field.type = "text";
field.placeholder = "@ник";
field.autocapitalize = "off";
field.autocomplete = "off";
field.spellcheck = false;
const go = el("button", "input__send", ">");
go.type = "submit";
row.append(prompt, field, go);
const note = message();
form.append(row, note);
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (go.disabled) {
return;
}
setNote(note, "");
// Ник вводят как в списке: с «@» или без. Регистр не хранится —
// ники строчные (ADR-019).
const nick = field.value.trim().replace(/^@/, "").toLowerCase();
if (nick === "") {
field.focus();
return;
}
field.value = nick;
go.disabled = true;
try {
await sync.openDm(nick);
ctx.go(`#/dm/${nick}`);
} catch (err) {
setError(note, ctx.errorText(err));
field.focus();
} finally {
go.disabled = false;
}
});
body.append(form);
root.append(body);
field.focus();
}
function head(ctx) {
const bar = el("div", "head");
const back = el("button", "back", "назад");
back.type = "button";
back.addEventListener("click", () => ctx.go("#/"));
bar.append(back, el("span", "title", "новый чат"));
return bar;
}
+16 -11
View File
@@ -1,19 +1,22 @@
// Каркас: сайдбар со списком чатов и место под экран — docs/ui.md, «Каркас»
// и «Список чатов». Чаты появятся на этапе 2, секции пока пустые.
// и «Список чатов».
import { el, mark } from "./dom.js";
import { mount } from "./chats.js";
// frame отдаёт корень и место под экран. screen — что показывать
// на мобильном, где виден один экран за раз: "list" или "screen".
export function frame(ctx, screen) {
// frame отдаёт корень, место под экран и отписку списка чатов.
// screen — что показывать на мобильном, где виден один экран за раз:
// "list" или "screen". active — чат, который сейчас открыт.
export function frame(ctx, screen, active = null) {
const root = el("div", "shell");
root.dataset.screen = screen;
const main = el("main", "main");
root.append(side(ctx), main);
return { root, main };
const { nav, dispose } = side(ctx, active);
root.append(nav, main);
return { root, main, dispose };
}
function side(ctx) {
function side(ctx, active) {
const nav = el("nav", "side");
const brand = el("div", "brand");
@@ -21,9 +24,11 @@ function side(ctx) {
nav.append(brand);
const list = el("div", "list");
for (const title of ["каналы", "личные"]) {
list.append(el("h2", "section", title), el("ul", "items"));
}
const add = el("button", "item item--new", "+ новый чат");
add.type = "button";
add.addEventListener("click", () => ctx.go("#/new"));
const items = el("div");
list.append(add, items);
nav.append(list);
const me = el("button", "me");
@@ -34,5 +39,5 @@ function side(ctx) {
me.addEventListener("click", () => ctx.go("#/settings"));
nav.append(me);
return nav;
return { nav, dispose: mount(items, ctx, active) };
}
+104
View File
@@ -0,0 +1,104 @@
// ULID — идентификатор сообщения: 48 бит миллисекунд и 80 бит случайности,
// Crockford base32, 26 символов (docs/crypto.md, «Идентификаторы»).
//
// Заглавные буквы обязательны: идентификатор входит в AAD шифротекста
// побайтно, и сервер строчные не принимает.
//
// Модуль не знает про DOM: его можно импортировать в node и прогнать.
// crockford — алфавит base32 без I, L, O и U.
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const TIME_LEN = 10; // 50 бит, старшие два обязаны быть нулевыми
const RANDOM_LEN = 16; // 80 бит
const RANDOM_BYTES = 10;
export const ULID_LEN = TIME_LEN + RANDOM_LEN;
// MAX_TIME — предел 48 бит: дальше метка времени в ULID не помещается.
const MAX_TIME = 2 ** 48 - 1;
// Последняя выданная миллисекунда и её случайная часть. Внутри одной
// миллисекунды случайная часть инкрементируется (docs/crypto.md):
// два сообщения, набранные подряд, не получают одинаковый идентификатор
// и сортируются в порядке отправки.
let lastMs = -1;
const lastRandom = new Uint8Array(RANDOM_BYTES);
export function ulid(now = Date.now()) {
const ms = Math.floor(now);
if (!Number.isSafeInteger(ms) || ms < 0 || ms > MAX_TIME) {
throw new RangeError("время вне 48 бит");
}
if (ms === lastMs) {
bump(lastRandom);
} else {
lastMs = ms;
globalThis.crypto.getRandomValues(lastRandom);
}
return encodeTime(ms) + encodeRandom(lastRandom);
}
// ulidTime — метка времени идентификатора в миллисекундах; null, если
// это не ULID. Сервер считает ту же величину и сравнивает со своими
// часами: расхождение больше пяти минут — clock_skew (ADR-017).
export function ulidTime(id) {
if (typeof id !== "string" || id.length !== ULID_LEN) {
return null;
}
let ms = 0;
for (let i = 0; i < ULID_LEN; i += 1) {
const value = ALPHABET.indexOf(id[i]);
if (value < 0) {
return null;
}
if (i < TIME_LEN) {
ms = ms * 32 + value;
}
}
return ms > MAX_TIME ? null : ms;
}
export function validUlid(id) {
return ulidTime(id) !== null;
}
// bump увеличивает случайную часть на единицу. Переполнение всех 80 бит
// внутри одной миллисекунды невозможно на практике; если оно всё же
// случилось, берём новые случайные байты.
function bump(bytes) {
for (let i = bytes.length - 1; i >= 0; i -= 1) {
if (bytes[i] < 255) {
bytes[i] += 1;
return;
}
bytes[i] = 0;
}
globalThis.crypto.getRandomValues(bytes);
}
function encodeTime(ms) {
const out = new Array(TIME_LEN);
let rest = ms;
for (let i = TIME_LEN - 1; i >= 0; i -= 1) {
out[i] = ALPHABET[rest % 32];
rest = Math.floor(rest / 32);
}
return out.join("");
}
// encodeRandom режет 80 бит на 16 групп по 5: остатка нет.
function encodeRandom(bytes) {
let out = "";
let acc = 0;
let bits = 0;
for (let i = 0; i < bytes.length; i += 1) {
acc = (acc << 8) | bytes[i];
bits += 8;
while (bits >= 5) {
bits -= 5;
out += ALPHABET[(acc >>> bits) & 31];
}
}
return out;
}