Мессенджер в одном HTML-файле: Git как storage, browser как runtime
- пятница, 19 июня 2026 г. в 00:00:07
Некоторое время назад я сделал странный pet project: мессенджер, который состоит из одного HTML-файла.

Без бекенда и базы данных (почти). Без регистрации. Без WebSocket. Без npm и сборки. Хотя, тут как посмотреть. Сообщения хранятся в git-репозитории. Проект называется Macaroni Messenger.
Сначала это выглядело как шутка уровня:
а что если вместо сервера использовать GitHub?
Потом оказалось, что браузер уже умеет достаточно много, git-хостинги уже дают достаточно API, а JSON достаточно скучный, чтобы на нём внезапно начал держаться чат.
Спойлер: оно работает, к сожалению...

Идея была простая:
browser + HTML + JavaScript + localStorage + IndexedDB + git-host API = messenger без backend
Если очень грубо, обычный мессенджер выглядит так:
client -> backend -> database
Macaroni выглядит так:
messenger.html -> git host -> git repository
То есть HTML-файл является клиентом, git-хостинг является transport/storage, а репозиторий является source of truth.
Никакого собственного сервера у Macaroni нет. Именно это было главным ограничением проекта.
Я сразу зафиксировал несколько правил:
один messenger.html;
без backend;
без базы данных;
без зависимостей и сборки;
без realtime-инфраструктуры;
git repository как storage и transport;
только имеющиеся browser APIs как runtime;
Это важные ограничения в основе архитектуры. Macaroni не пытается быть "правильным" production мессендером. Он отбрасывает принятую конвенцию отвечает на другой вопрос:
Насколько далеко можно уехать, если у тебя есть только HTML-файл, браузер и git?
Ответ: дальше, чем кажется.
В репозитории сообщения лежат не магически, а обычными JSON-файлами. Структура выглядит примерно так:
.macaroni/ protocol.json users/ SA6E.json AG01.json AG02.json chats/ chat_20260613_agent_room/ meta.json members.json messages/ 2026/ 06/ 15/ 2026-06-15T08-30-00.000Z_AG01_demo.json receipts/ SA6E/ 2026/ 06/ 15/ 2026-06-15T08-31-00.000Z_SA6E_....json inbox/ AG02/ 2026-06-15T08-30-00.000Z_AG01_demo.json
Это называется Macaroni Protocol v1.
Протокол намеренно тупой:
chat metadata — JSON;
members — JSON;
message — JSON;
inbox notification — JSON;
read receipt — JSON;
path предсказуемый;
файл можно открыть глазами;
файл можно починить руками.
Никакого бинарного формата. Никакой магии. Никакого "откройте наш SDK, чтобы понять, что тут произошло". Можно прямо в браузере или IDE ходить по репозиторию и читать сообщения, если нужен дебаг.
Это не значит, что проект примитивный, просто большинство решений в проекте осознанно представляют собой весьма элегантный велосипед с квадратными колесами, который удивительно эффективно, а самое важное — зрелищно решает вопрос в лоб.
Сообщение выглядит так:
{ "version": 1, "id": "2026-06-15T08-30-00.000Z_AG01_demo", "chat_id": "chat_20260613_agent_room", "type": "text", "from": "AG01", "from_name": "Agent One", "to": ["AG02"], "created_at": "2026-06-15T08:30:00.000Z", "text": "Hello from one HTML file", "reply_to": null, "attachments": [], "meta": { "client": "Macaroni Messenger JS 0.1.0" }, "signature": null }
Это обычный JSON. text — обычная строка. Если включён encryption plugin, то text становится зашифрованной строкой вида:
MACARONI1.01:eyJ2IjoiMS4wMSIsImFsZyI6...
Но базовый протокол от этого не меняется. Core всё ещё видит Protocol v1 message. Плагин просто меняет содержимое поля text.
Основная проблема git-backed чата: как быстро понять, что мне что-то пришло? Можно каждый раз обходить все chats/messages по датам. Это работает. Тут можно было нарисовать график с экспонентой, но это душно и не весело. Поэтому в файлах просто есть inbox hints:
.macaroni/inbox/<CLIENT_ID>/<message_id>.json
Пример:
{ "version": 1, "recipient_id": "AG02", "message_id": "2026-06-15T08-30-00.000Z_AG01_demo", "chat_id": "chat_20260613_agent_room", "message_path": ".macaroni/chats/chat_20260613_agent_room/messages/2026/06/15/2026-06-15T08-30-00.000Z_AG01_demo.json", "created_at": "2026-06-15T08:30:00.000Z", "type": "message" }
Это не отдельная копия сообщения. Это указатель. Получатель может быстро посмотреть свой inbox, найти новые hints и дочитать message file.
Git здесь используется очень буквально. Сообщение — это файл. Отправка сообщения — это commit. История сообщений — это history. Ветка — это storage namespace.
Это не "database on git" в универсальном смысле. Это маленький append-friendly файловый протокол, который достаточно хорошо ложится на git.
Преимущества протокола:
история уже есть;
diff уже есть;
backup уже есть;
hosting уже есть;
auth/token model уже есть;
branch model уже есть;
можно открыть репозиторий и увидеть данные.
Недостатки протокола:
это не realtime;
большие repo будут тормозить;
у API есть rate limits;
conflict handling нужен;
git-host APIs отличаются;
это всё ещё странная идея.
Сначала .macaroni/ лежала прямо в ветке main. То есть репозиторий выглядел так:
README.md messenger.html docs/ .macaroni/
Это работало, и доказывало что концепт упоротый, но рабочий. Правда, выглядело всё так, будто чат пришёл в документацию и сел прямо на README. Поэтому когда стало поздно что-то менять — появилась отдельная ветка для "storage branch". Теперь репозиторий можно организовать так:
main: messenger.html README.md docs/ macaroni: .macaroni/
В настройках клиента есть поле:
Storage branch: macaroni
Для всех новых пользователей дефолтной веткой для хранения сообщений становится — macaroni. Это очень удобно, потому что теперь мессенджер может поселиться вообще в любом репозитории, в котором разрешены пулл реквесты с ветками.
Для обратной совместимости те, у кого storageBranch был в мейне — продолжают использовать старую ветку, чтобы обновление HTML-файла не "потеряло" существующие чаты.
В идеале надо бы это выпилить, но мы пишем отказоустойчивую распределенную систему обмена чем угодно на HTML, поэтому нам лень, и этот хвост теперь в проекте навсегда. Если же storage branch не существует, адаптер попытается создать эту ветку от source branch.
Macaroni не имплементирует полный git client в браузере, потому что нам от гита нужен только CRUD, а точнее вообще CRU.
Raw SSH/git protocol из браузера — это отличная идея для проекта, и если кто-то хочет ее реализовать, в виде одного портативного HTML файла, обязательно добавьте меня в контрибьютеры.
Вместо того чтобы тянуть пакеты или писать велосипед, используется подход вида:
Если git-хостинг уже даёт HTTP API для чтения/записи файлов, браузер может использовать его как transport.
Сейчас в проекте уже реализованы адаптеры для: GitHub, GitLab, GitVerse, Gitea, Forgejo. Желающий может прикрутить любой адаптер, потому что по сути в большинстве GIT- совместимых хостингах файлов меняются лишь адреса ручек и способы авторизации.
Концептуально гит хостингом может быть любая машина с установленным гитом и адаптером, смотрящим в интернет. И никто не мешает форкнуть свой чат и поднять его даже в локальной сети.
Общий контракт такой:
{ readHead(config) {}, ensureBranch(config, branch, fromRef) {}, readFile(config, path) {}, readJson(config, path) {}, listFiles(config, path) {}, writeFile(config, path, content, message) {}, writeJson(config, path, value, message) {} }
GitHub adapter использует REST Contents API, который дает практически любой гит хостинг. При чем совершенно буквально, не нарушая никаких пунктов лицензионных соглашений. Мы делаем то же самое что и все остальные: пишем и читаем файлы.
Чтение файла:
GET /repos/{owner}/{repo}/contents/{path}?ref={branch}
Запись файла:
PUT /repos/{owner}/{repo}/contents/{path}
Тело запроса примерно такое:
{ "message": "Macaroni: send message 2026-06-15T08-30-00.000Z_AG01_demo", "content": "base64...", "branch": "macaroni", "sha": "old-file-sha-if-update" }
Если файла ещё нет, sha не нужен. Если файл обновляется, sha нужен.
Если GitHub отвечает 409 Conflict, клиент перечитывает metadata и пробует один раз снова.
Отправка сообщения не обязана сразу попасть в remote. В браузере есть local outbox. Сначала сообщение создаётся локально, попадает в IndexedDB и отображается в UI. Это дает ощущение общения в реальном времени.
Потом flush пытается записать сообщение в git. Упрощённо:
async function flushOutbox(profile) { await reindexRemoteIfChanged(profile); for (const item of await listOutbox()) { try { await sendRemoteMessage(profile, item.payload); await deleteOutbox(item.id); } catch (error) { await markOutboxError(item.id, error.message); break; } } }
Перед push клиент делает refresh/reindex. Потому что Git никуда не убежит но конфликт записи всё равно может прийти и испортить настроение.
Если у пользователя нет write token, remote profile работает как read-only. В read-only режиме:
история читается;
messages отображаются;
composer скрыт;
create chat недоступен;
join chat недоступен;
UI честно пишет, что нужен token с правом записи.
Это важнее, чем кажется. В первых приближениях можно было написать сообщение без токена, или с токеном без прав на запись, оно падало в outbox и ждало светлого будущего.
И вычищать это в браузере было достаточно трудно. Технически объяснимо. Пользовательски странно. Теперь проще:
нет write token -> нет composer
В браузере используется два вида локального состояния.
localStorage — выбран потому что простой, в него мы пишем просто то, что пишется редко, чаще всего вообще один раз:
profile;
token;
language;
plugin settings;
encryption settings.
IndexedDB — выбрана по приколу, потому что его все хейтят, но оно есть практически везде, и работает как часы:
local message cache;
chats;
users;
outbox;
local test repo;
metadata вроде последнего sync.
Git остаётся источником истины всегда. IndexedDB — это просто быстрый кеш. Если IndexedDB сломался, его можно пересобрать из repo через reindex. Если у тебя не SSD, жесткий будет хрустеть, а ты физически услишишь как приходят сообщения.
Если git repo сломался, у вас уже не проблемы с кешем, а полная утрата всей переписки. Это не баг, а фича.
В какой-то момент стало понятно, что core не должен знать обо всём. Поэтому появился browser-side plugin boundary. Plugin может иметь hooks:
transformOutgoingMessage(message, context) transformIncomingMessage(message, context) mountSettings(container, context)
Core создаёт Protocol v1 message. Перед записью сообщение проходит через плагины, которые делают с ним что хотят. Перед показом incoming message тоже проходит через plugin трансформации.
Это позволяет, например, сделать encryption plugin без изменения Protocol v1. Core всё ещё хранит обычный message. Plugin просто превращает message.text в другую строку.
Тут в принципе можно прикрутить что угодно: от логгера до отправки файлов в 500 гигабайт чанками через гитхаб. Ну или адаптер для прикрепления любого медиа с хранением в S3 совместимом хранилище, или webdav. И от этого не пострадает протокол или транспорт. Архитектура.
Это отдельный мем. Изначально идея мессенджера была "а давайте хранить все сообщения публично, пускай каждый желающий читает". Но потом возникла мысль "а давайте сделаем шифрование со сложностью O(log(n)), которое слегка усложнит чтение сообщений.
Encryption писался как plugin, чтобы подтвердить пруф оф концепт плагинов. Это очень важно. Macaroni Protocol v1 не меняется. Файловая структура не меняется. Transport не меняется.
Меняется только message.text. Формула намеренно простая:
secret + salt + message context -> tiny PRNG -> XOR stream
Контекст включает:
repo;
chat;
sender;
recipients;
message id;
created_at;
plaintext length.
Material выглядит примерно так:
macaroni-1.01|secret|salt|repo|chat|from|to|message_id|created_at|length
Потом material превращается в deterministic byte stream. Потом plaintext XOR-ится с этим stream. Пример функции:
function xorTinyPrng(bytes, material) { var out = new Uint8Array(bytes.length); var random = xorshift32(fnv1a(material, 0x811c9dc5)); var word = 0; var used = 4; for (var i = 0; i < bytes.length; i++) { if (used >= 4) { word = random(); used = 0; } out[i] = bytes[i] ^ ((word >>> (used * 8)) & 255); used++; } return out; }
Да, это не Signal. Да, это не PGP (хотя никто не мешает сюда примотать PGP за 2 минуты). Да, это невероятно просто. Так никто не делает. Ну кроме нас.
И да, если ваш secret — 12345, вы принесли деревянную дверь и попросили считать её сейфом. Но если secret и salt — это независимые PGP-sized куски key material, перебор становится плохой стратегией.
Даже если практически полезными окажутся “всего” 128 бит энтропии:
2^128 ~= 3.4e38
При фантастических 10^12 проверок в секунду это примерно 10^19 лет. Возраст Вселенной — порядка 10^10 лет. То есть сильный secret переносит атаку из подобрать ключ в добыть ключ.
А это уже не криптография. Это старый добрый голливудский фильм про хакеров, флешку, backup.zip и человека, который сам отправил файл не туда.
Самая интересная часть encryption — не XOR. Самая интересная часть — file-as-key model.
В portable mode messenger.html может содержать:
repo URL;
CLIENT_ID;
token;
plugin settings;
encryption secret;
salt.
То есть файл становится capability artifact.
получил файл -> получил доступ потерял файл -> потерял доступ слил файл -> слил доступ
В документации это формулируется так:
Файл - это ключ. Ключ - это файл. Передал файл - добавил участника. Потерял файл - потерял чат.
Если secret/salt были PGP-sized, а файл удален окончательно, это не "забытый пароль". Проще отправиться в параллельную вселенную и взять файл там. PGP-sized secret не восстанавливают. Его либо хранят, либо оплакивают.
Важно: Macaroni не продаёт приватность.
Если repo публичный и сообщения plaintext — они публичные.
Если repo приватный — они доступны тем, у кого есть доступ к repo.
Если включено encryption — в repo лежит encrypted pasta, но файл с ключом становится важной вещью.
Если файл с ключом украли — значит, его украли.
Это честная модель. Не универсальная. Не "secure messenger for activists". Не "military-grade communication platform". Но можно устроить знатный холивар если предыдущие утверждения пытаться доказать или опровергнуть.
Это эксперимент.
Самое интересное оказалось не в коде. Интереснее побочные эффекты.
GitHub, GitLab, Gitea и другие обычно думают, что хранят исходный код.
Macaroni добавляет туда переписку. Для git это обычные JSON-файлы. Для пользователя это чат. Для юриста это неприятный вопрос.
С точки зрения пользователя это "просто страница". С точки зрения браузера это application runtime. С точки зрения администратора это файл, который можно принести на флешке, прислать по почте и открыть в браузере, а с прямыми руками — в любой electron обертке от любого вендора.
Это не backdoor. Это не exploit. Это обычный браузер, который уже умеет всё, чтобы ваш файл "секретные чертежи НЛО.zip" оказался там, где не планировалось.
Отдельная ветка macaroni может хранить не только сообщения пользователей, но и project memory. То есть сам проект может иметь:
main branch -> code/docs macaroni branch -> project memory/lore/chat history
Это уже почти игровая механика. Main quest и lore branch. Шутка замкнулась сама на себя. Например, я храню там какую-то переписку с агентом, чтобы не терять контекст при сжатии. В общем сходите посмотрите.
Чтобы не было иллюзий:
realtime нет;
polling есть;
в GitHub API есть rate limits;
token хранится в localStorage;
encryption не является audited crypto;
browser support ограничен;
большие repo будут медленными;
full-text search простой;
identity model минимальная;
moderation нет;
server-side recovery нет;
если потеряли file-as-key, потеряли доступ;
если repo переполнен, создайте новый repo — это архитектура масштабирования.
Macaroni не должен заменять нормальные мессенджеры. Он показывает, что граница между "документом", "приложением", "протоколом" и "хранилищем" проходит не там, где обычно удобно думать.
Можно было бы сделать:
npm package;
Vite;
React;
backend;
database;
WebSocket;
docker-compose;
Kubernetes;
observability stack;
Helm chart;
500 МБ wrapper, чтобы открыть HTML.
Но тогда проект перестал бы отвечать на свой главный вопрос.
Главный вопрос:
что можно сделать одним HTML-файлом?
Поэтому messenger.html остаётся одним файлом. Да, он стал большим. Да, внутри уже есть adapters, local index, outbox, plugin boundary и encryption.
Да, код надо иногда причёсывать, чтобы он не стал совсем лапшой. Но это всё ещё один файл. И это важно. Это фича.
Macaroni Messenger не доказывает, что всем надо писать мессенджеры в HTML. Не надо. Пожалуйста. Но он показывает, что:
browser runtime + git-host API + JSON protocol + localStorage + IndexedDB
— уже достаточно, чтобы собрать маленький мессенджер. В нутри есть:
transport adapters;
outbox;
local cache;
plugin boundary;
optional encryption;
git-backed protocol;
file-as-key model.
Macaroni Messenger не должен был работать. Но Git умеет хранить файлы. Браузер умеет выполнять JavaScript. JSON умеет быть достаточно скучным, чтобы на нём держался чат.
Этого оказалось достаточно.