Мне давно нравится идея хранить заметки обычными Markdown‑файлами в своём репозитории на GitHub. Файлы остаются твоими, их можно открыть на github.com или в любом редакторе, а история изменений достаётся даром, это просто коммиты.
Был проект, который делал ровно это: BatNoter, 2,4 тысячи звёзд на GitHub. Веб‑приложение на React, заметки в вашем репозитории. Но у него был свой сервер на Go: через него шёл вход по OAuth и все запросы к GitHub. В конце 2022 года сервер batnoter.com перестал отвечать. В issue “Log in with github error” люди до сих пор спрашивают, есть ли решение.
Я переписал приложение так, чтобы сервер был не нужен вовсе. Получился Notewing: https://perruer.github.io/notewing/ (MIT, код на GitHub). В статье расскажу, как устроена синхронизация без бэкенда, где были подводные камни и как всё это тестировать, не трогая настоящий GitHub.

Почему сервер не нужен
Сервер BatNoter решал две задачи:
-
OAuth. Для обмена кода авторизации на токен нужен
client_secret, а секрет в браузере не спрячешь. -
Прокси к API. Он брал токен из своей базы и ходил в GitHub от имени пользователя.
Вторая задача в браузере решается сама: api.github.com отдаёт CORS‑заголовки, и fetch из любого сайта с заголовком Authorization работает.
С первой сложнее. У GitHub есть device flow, где секрет не нужен, но эндпоинт github.com/login/device/code CORS не поддерживает. Из обычной веб‑страницы его не вызвать.
Я выбрал fine‑grained personal access token. Пользователь создаёт токен сам и даёт ему доступ ровно к одному репозиторию с правом Contents: Read and write. По сравнению с OAuth‑прокси это даже безопаснее:
-
токен никогда не покидает браузер и уходит только на
api.github.com; -
он не видит остальные репозитории;
-
его можно отозвать в один клик на GitHub.
Цена — лишний шаг при первом входе. Чтобы шаг был короче, ссылка на создание токена сразу заполняет название и описание.
Модель данных: снимок и очередь
Главная идея: приложение ничего не пишет в GitHub сразу. Любая правка сначала попадает в локальную очередь в IndexedDB. Поэтому:
-
интерфейс не ждёт сеть;
-
без интернета можно спокойно работать;
-
перезагрузка страницы ничего не теряет.
Локально хранятся две структуры:
interface Snapshot {
head: string | null; // коммит, на котором мы стоим
tree: string | null; // его дерево
files: Record<string, string>; // путь -> sha блоба
}
interface Change {
text?: string; // новый текст заметки
data?: string; // двоичный файл (картинка), base64
blob?: string; // уже существующий блоб (для переноса)
del?: true; // удаление
base: string | null; // sha файла, когда правка началась
rev: number; // номер правки
}
Рабочая копия — это snapshot.files, поверх которого наложена очередь pending: Record<path, Change>.
Поле base — ключ ко всему остальному: оно запоминает, от какой версии файла мы отталкивались.
Небольшая, но приятная деталь: sha блоба git можно посчитать прямо в браузере. Это SHA-1 от строки blob <длина><содержимое>:
export async function blobSha(data: string | Uint8Array): Promise<string> {
const body = typeof data === "string" ? utf8(data) : data;
const head = utf8(`blob ${body.length}`);
const all = new Uint8Array(head.length + body.length);
all.set(head);
all.set(body, head.length);
const digest = await crypto.subtle.digest("SHA-1", all);
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
Благодаря этому, если пользователь напечатал текст, а потом вернул его к исходному, правка просто исчезает из очереди: sha совпал с base. Лишнего коммита «ничего не изменил» не будет.
Отправка: один коммит через Git Data API
Самый очевидный способ писать в репозиторий — Contents API (PUT /repos/{owner}/{repo}/contents/{path}). Но каждый вызов этого метода делает отдельный коммит. Переименование заметки превращается в два коммита, «создать» и «удалить», а перенос папки с двадцатью заметками — в сорок.
Поэтому Notewing работает на уровень ниже, через Git Data API, как это делает сам git:
-
POST /git/treesсbase_tree— новое дерево. Текстовые файлы передаются прямо полемcontent, удаления — какsha: null, картинки сначала загружаются черезPOST /git/blobs. -
POST /git/commits— коммит с этим деревом и родителемhead. -
PATCH /git/refs/heads/mainсforce: false— передвинуть ветку.
Сколько бы изменений ни накопилось, всё уходит одним коммитом. При переносе файла не нужно даже перекачивать содержимое: в дерево кладётся sha существующего блоба под новым путём.
for (const [path, c] of batch) {
if (c.del) entries.push({ path, mode: "100644", type: "blob", sha: null });
else if (c.text !== undefined) entries.push({ path, mode: "100644", type: "blob", content: c.text });
else if (c.data !== undefined) entries.push({ path, mode: "100644", type: "blob", sha: await gh.createBlob(ref, c.data) });
else entries.push({ path, mode: "100644", type: "blob", sha: c.blob! }); // перенос
}
const tree = await gh.createTree(ref, snapshot.tree, entries);
const commit = await gh.createCommit(ref, message(batch), tree, [snapshot.head]);
await gh.updateRef(ref, commit); // force: false
force: false — это наша защита от гонок. Если с другого устройства в ветку успели что‑то закоммитить, GitHub ответит 422 Update is not a fast forward. Тогда мы подтягиваем свежий снимок, перебазируем очередь и пробуем ещё раз.
Перебазирование и конфликты
При подтягивании новой версии для каждой правки в очереди сравниваются три sha:
-
base— версия, от которой мы начали; -
now— что сейчас на GitHub; -
mine— что получится после нашей правки.
for (const [path, c] of Object.entries(this.pending)) {
const now = remote[path] ?? null;
if (now === c.base) continue; // там файл не трогали
if (now === await this.targetSha(c)) { drop(path); continue; } // обе стороны пришли к одному
if (c.del) { drop(path); continue; } // мы удалили, там изменили: оставляем их версию
if (now === null) { c.base = null; continue; } // там удалили, мы изменили: создаём заново
// изменили с обеих сторон
saveAsConflictCopy(path, c); // "Plan (conflict 2026-09-22 1530).md"
}
Я сознательно не стал делать трёхстороннее слияние текста. Для заметок самый понятный вариант такой: путь остаётся за удалённой версией, а ваш текст сохраняется рядом копией. Пользователь получает уведомление с кнопкой «Открыть». Ничего не теряется и ничего не склеивается молча.
Отдельно пришлось обработать правки, которые пользователь сделал во время отправки. Для этого и нужен rev: после успешного коммита из очереди убираются только те записи, чей rev не изменился. У остальных просто обновляется base.
Подводный камень: пустой репозиторий
Пока всё было проверено на репозитории с README, всё работало. На пустом репозитории всё сломалось: Git Data API отвечает 409 Git Repository is empty, причём и на создание блоба, и на создание дерева.
Решение — первый файл отправляется старым Contents API, который умеет создавать первый коммит. Всё остальное уходит следующим циклом через Git Data API.
Подводный камень: управляемый редактор
Редактор — CodeMirror 6. Сначала я подключил его как обычный управляемый компонент: есть value, есть onChange, а если value снаружи отличается от документа, документ заменяется. Это дало две ошибки, одна другой хуже.
Первая. При быстром наборе редактор зависал намертво. Пользователь набирает «a», потом «b». Документ уже …ab, а компонент ещё не перерисовался и передаёт старое value = …a. Редактор «исправляет» документ обратно на …a, это вызывает onChange, и два состояния начинают бесконечно гонять друг друга.
Вторая, коварнее. Любая программная замена документа тоже вызывала onChange и считалась правкой пользователя. Если в момент открытия заметки приходила свежая версия с GitHub, старый текст мог уйти в очередь как «изменение» и затереть её.
Обе ошибки поймали E2E‑тесты, и лечение у них одно: внешние изменения передаются в редактор не значением, а явной ревизией, и помечаются аннотацией.
const external = Annotation.define<boolean>();
// в редакторе: вызываем onChange только для правок пользователя
EditorView.updateListener.of((u) => {
if (u.docChanged && u.transactions.some((tr) => !tr.annotation(external))) onChange(u.state.doc.toString());
});
// снаружи пришёл новый текст (обновление с GitHub, галочка в просмотре, восстановленная версия)
useEffect(() => {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value }, annotations: external.of(true) });
}, [revision]);
Картинки из приватного репозитория
Скриншот можно вставить в заметку из буфера обмена. Он сохраняется в assets/ в репозитории, а в текст вставляется относительная ссылка . Благодаря этому картинка видна и на github.com.
А вот показать её в предпросмотре не так просто: для приватного репозитория raw.githubusercontent.com без авторизации отдаст 404. Поэтому предпросмотр находит относительные src, загружает файл через API с токеном (или из локальной очереди, если картинку ещё не отправили) и подставляет blob: URL.
Безопасность: токен на странице
Раз токен живёт в браузере, любой XSS превращается в утечку токена. А заметки — это произвольный HTML из репозитория. Поэтому:
-
весь отрендеренный Markdown проходит через DOMPurify. Разрешены чекбоксы задач, внешние ссылки получают
rel="noopener noreferrer"; -
в собранной версии стоит строгая CSP:
script-src 'self',connect-srchttps://api.github.comи никаких сторонних доменов.
Даже если санитайзер что‑то пропустит, отправить токен на чужой сервер скрипт не сможет. В E2E есть отдельный тест, который кладёт в заметку <img onerror>, <script> и javascript:‑ссылку и проверяет, что ничего не выполнилось.
Офлайн
Три слоя:
-
IndexedDB — снимок репозитория, тексты заметок (по sha блоба, поэтому неизменные файлы не перекачиваются) и очередь правок;
-
service worker — кеширует оболочку приложения, в том числе при размещении в подпапке. Поэтому установленное PWA стартует без сети;
-
фоновая загрузка — после синхронизации в фоне докачиваются тексты всех заметок, чтобы работал поиск.
Интерфейс в это время перерисовывается пачками, а не на каждую заметку. На репозитории из тысячи файлов иначе всё тормозит.
Как это тестировать без GitHub
Больше всего времени сэкономило одно решение: я написал FakeGitHub — эмуляцию нужной части GitHub REST API в памяти. Она умеет:
-
блобы, деревья, коммиты и ветки;
-
ответ
422на не fast‑forward; -
409на пустом репозитории; -
историю файла.
Эта эмуляция используется в трёх местах:
-
Юнит‑тесты движка синхронизации: конфликты, гонка при отправке, правки во время отправки, офлайн с перезагрузкой, пустой репозиторий.
-
E2E‑тесты в Chrome и Firefox. Puppeteer перехватывает запросы к
api.github.comи отвечает из той же эмуляции. В Firefox (WebDriver BiDi) пришлось брать тело запроса черезfetchPostData(): синхронныйpostData()там не поддерживается. -
Демо‑режим на сайте. Кнопка «Попробовать демо» запускает приложение на той же эмуляции прямо в странице, без токена и без сети.
Плюс отдельный живой тест, который прогоняет те же сценарии на настоящем GitHub в одноразовом репозитории.
Что получилось
-
Заметки — обычные
.mdфайлы, репозиторий из BatNoter подходит как есть. -
Работает офлайн, ставится как приложение.
-
Поиск (Ctrl+K), картинки, история версий с восстановлением, перетаскивание, тёмная тема, интерфейс на русском, английском и китайском.
-
Нет сервера, нет аналитики, нет аккаунта. Для размещения у себя достаточно распаковать архив на любой статический .
Демо без регистрации: https://perruer.github.io/notewing/ Код: https://github.com/Perruer/notewing
Чего пока нет: GitLab и Gitea (их просят в issues BatNoter), шифрование заметок. Если бы вам это пригодилось, напишите в комментариях, какой вариант нужнее.
Проект открытый и бесплатный. Если он вам пригодится, поддержать разработку можно на Boosty.
Автор: Perruer
