javascript

Мессенджер в одном HTML-файле: Git как storage, browser как runtime

  • пятница, 19 июня 2026 г. в 00:00:07
https://habr.com/ru/articles/1049048/

Некоторое время назад я сделал странный pet project: мессенджер, который состоит из одного HTML-файла.

Без бекенда и базы данных (почти). Без регистрации. Без WebSocket. Без npm и сборки. Хотя, тут как посмотреть. Сообщения хранятся в git-репозитории. Проект называется Macaroni Messenger.

Сначала это выглядело как шутка уровня:

а что если вместо сервера использовать GitHub?

Потом оказалось, что браузер уже умеет достаточно много, git-хостинги уже дают достаточно API, а JSON достаточно скучный, чтобы на нём внезапно начал держаться чат.

Спойлер: оно работает, к сожалению...

А у вас есть HTML репозиторий c 80+ звездами?
А у вас есть HTML репозиторий c 80+ звездами?

Что Я Хотел Проверить

Идея была простая:

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?

Ответ: дальше, чем кажется.

Файловая Модель .macaroni/

В репозитории сообщения лежат не магически, а обычными 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.

Inbox Как Receive Hint

Основная проблема 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 Как Storage

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 отличаются;

  • это всё ещё странная идея.

Storage Branch

Сначала .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.

Transport Adapters

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 через API

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 и пробует один раз снова.

Отправка сообщений через Outbox

Отправка сообщения не обязана сразу попасть в 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 никуда не убежит но конфликт записи всё равно может прийти и испортить настроение.

Read-only Mode

Если у пользователя нет write token, remote profile работает как read-only. В read-only режиме:

  • история читается;

  • messages отображаются;

  • composer скрыт;

  • create chat недоступен;

  • join chat недоступен;

  • UI честно пишет, что нужен token с правом записи.

Это важнее, чем кажется. В первых приближениях можно было написать сообщение без токена, или с токеном без прав на запись, оно падало в outbox и ждало светлого будущего.

И вычищать это в браузере было достаточно трудно. Технически объяснимо. Пользовательски странно. Теперь проще:

нет write token -> нет composer

Local State

В браузере используется два вида локального состояния.

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 сломался, у вас уже не проблемы с кешем, а полная утрата всей переписки. Это не баг, а фича.

Plugin Boundary

В какой-то момент стало понятно, что 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. И от этого не пострадает протокол или транспорт. Архитектура.

Encryption 1.01

Это отдельный мем. Изначально идея мессенджера была "а давайте хранить все сообщения публично, пускай каждый желающий читает". Но потом возникла мысль "а давайте сделаем шифрование со сложностью 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 и человека, который сам отправил файл не туда.

File As Key

Самая интересная часть 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". Но можно устроить знатный холивар если предыдущие утверждения пытаться доказать или опровергнуть.

Это эксперимент.

Что Получилось Неожиданно

Самое интересное оказалось не в коде. Интереснее побочные эффекты.

1. Git-host становится message storage

GitHub, GitLab, Gitea и другие обычно думают, что хранят исходный код.

Macaroni добавляет туда переписку. Для git это обычные JSON-файлы. Для пользователя это чат. Для юриста это неприятный вопрос.

2. HTML-файл становится приложением

С точки зрения пользователя это "просто страница". С точки зрения браузера это application runtime. С точки зрения администратора это файл, который можно принести на флешке, прислать по почте и открыть в браузере, а с прямыми руками — в любой electron обертке от любого вендора.

Это не backdoor. Это не exploit. Это обычный браузер, который уже умеет всё, чтобы ваш файл "секретные чертежи НЛО.zip" оказался там, где не планировалось.

3. Repository становится памятью

Отдельная ветка 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 не должен заменять нормальные мессенджеры. Он показывает, что граница между "документом", "приложением", "протоколом" и "хранилищем" проходит не там, где обычно удобно думать.

Почему Один HTML

Можно было бы сделать:

  • 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 умеет быть достаточно скучным, чтобы на нём держался чат.

Этого оказалось достаточно.