Привет, Хабр! Меня зовут Паша, я разработчик Gramax — open source-платформы для управления технической документацией в подходе Docs as Code.
В этой статья я расскажу, как и для чего мы интегрировали Git в браузер, какие технологии использовали и какие технические решения приняли. А если подробнее: почему отказались от IsomorphicGit в пользу libgit2, каким образом мы собрали его под WebAssembly, как он работает с файлами и зачем вообще все это было нужно.
Зачем нам это нужно?
Когда я пришел в команду, проект перерождался из внутреннего приложения в то, что сейчас называется Gramax.
На тот момент у нас был серверный SSG на Next.js, который рендерил Markdown, и громоздкое Electron-приложение для локального предпросмотра весом 600mb+. Поддержка Git сводилась к API-эндпоинту докпортала, вызывавшему git pull
, а все остальное делалось вручную в VS Code или других редакторах.
Перед нами стояли следующие задачи:
Визуальный редактор. Возможность создавать и редактировать Markdown-файлы в WYSIWYG-редакторе, чтобы пользователи не думали о его синтаксисе.
Локальное хранение. Так как вся логика выполняется на клиенте, было важно, чтобы файлы также хранились на устройстве пользователя, без привязки к конкретным инструментам.
Версионирование. Docs as Code подразумевает работу с контентом как с кодом. Потому мы хотели сохранить все преимущества использования Git: история, сравнение версий, мерж реквесты с подсветкой изменений и так далее.
Кроссплатформенность. Пользователь сам может выбирать, где работать: в десктопном приложении для Windows, macOS и Linux или в браузере.
Подход Docs as Code требует навыков работы с Git и Markdown. Мы стремились сделать этот процесс доступным для всех, включая нетехнических спецов. Интеграция Git в браузер позволила бы объединить визуальный редактор и контроль версий в одном интерфейсе.
Если наша мотивация все еще звучит не очень убедительной, рекомендую почитать другие статьи о Gramax. В них как раз рассказываем, какая у нас концептуальная идея и зачем это все.
TL;DR: вкратце о реализации
Поначалу мы использовали IsomorphicGit, но вскоре поняли, что он не очень подходит под наши задачи. Как только наткнулись на Proof-Of-Concept компиляции libgit2 в WebAssembly, решили переходить на него.
Для компиляции используется Emscripten — тулчейн для компиляции C/C++ программ в WebAssembly, для Rust использовали таргет
wasm32-unknown-emscripten
.В качестве файловой системы используем WasmFS от Emscripten, который хранит файлы в OPFS. Однако, для компиляции WebAssembly-модуля нам понадобилось включить эксперементальные фича-флаги
bulk-memory
иatomics
. Это потребовало компиляции в том числе std при помощи флага-Zbuild-std
и перехода на nightly-тулчейн.Git-хостинги обычно не устанавливают CORS-заголовки, поэтому приходится использовать CORS-прокси.
Стандартный HTTP-транспорт libgit2 не подходит — использовали из PoC, но добавили обработку ошибок, коллбек прогресса запроса, возможность проставлять нужные нам заголовки.
С чего все начиналось. Реализация Git на чистом JS
На тот момент у нас уже была практически готова первая версия визуального редактора и прототип отдельного веб-приложения для редактирования. Само по себе оно работало неплохо, но нам нужно было интегрировать в него Git. Для этого мы решили использовать IsomorphicGit — реализацию Git на чистом JavaScript, как самое очевидное и простое решение.
Однако вскоре мы столкнулись с рядом проблем:
Сложное API. Документация была недостаточно подробной, а API — запутанным. Разобраться в некоторых операциях было непросто.
Неполный функционал. IsomorphicGit хорошо поддерживает базовый функционал, но как только начинаются малейшие дебри — начинается мучение. К примеру, однажды мы столкнулись с тем, что IsomorphicGit не поддерживал разрешение конфликтов, если в одной из веток файл был удалён. На тот момент мы добавили этот функционал сами — в этом Pull Request.
Проблемы с производительностью. Некоторые операции IsomorphicGit выполнял крайне неспешно. Конкретных цифр не приведу, но в памяти особенно отразился
git.log
.Поддержка. Развитие и поддержка IsomorphicGit отдана на откуп сообществу, а оригинальный автор уже давно не занимается проектом. Он имеет лишь пару мейнтейнеров, которые иногда отвечают на Issue и смотрят PR.
Как мы пришли к WebAssembly и libgit2
Мы окончательно поняли, что IsomorphicGit нам не подходит, когда десктопное приложение оказалось неспособным переварить репозиторий среднего размера в ~10k коммитов.
Мы искали решение, которое подходило бы для десктопа, браузера и докпортала. Достаточно быстро наткнулись на интересный Proof-Of-Concept, где libgit2 компилировалась в WebAssembly с помощью Emscripten. Это означало, что libgit2 может полностью подойти под наши запросы.
Библиотека libgit2 — хорошо зарекомендовавшая себя кроссплатформенная реализация Git на чистом C, которая заточена под использование в качестве библиотеки, а не отдельной программы. Она может предоставить нам лучшую производительность и стабильность, поддержку большего функционала и сохранение необходимой кроссплатформенности.
Компиляция libgit2 в WebAssembly при помощи Emscripten
Emscripten — это тулчейн для сборки C/C++ приложений в WebAssembly. Но и другие языки, компилятор которых использует LLVM в качестве бэкенда, он тоже может собрать.
Rust — как раз такой язык и поддерживает таргет wasm32-unknown-emscripten
. Мы попробовали собрать git2-rs и libgit2 вместе и, в целом, всё прошло легко, несмотря на несколько патчей, которые нам пришлось сделать:
Удалили
zlib-sys
из зависимостей для WebAssembly-таргетов. (ref) Emscripten предоставляет собственную реализацию zlib при указании флага-sUSE_ZLIB=1
. Компиляция обоих реализаций приводило к конфликтам.Кастомный транспорт для взаимодействия с сетью. (ref) Добавили кастомный HTTP-транспорт, взяв за основу код из PoC. Это позволило libgit2 взаимодействовать с сетью.
Вырезали стандартный HTTP-транспорт libgit2. (ref) Дефолтная реализация в любом случае не будет работать в WebAssembly, так она ещё и мешала кастомной. Поэтому добавили файл
libgit2/src/libgit2/transports/http.c
в исключения.
Почему Rust? У нас уже был прототип десктопного приложения (не путать с браузерным) на Tauri v2 и нам хотелось шарить код между платформами, да и в C/C++ ковыряться никто не горел желанием.
Чем не подошла стандартная компиляция WebAssembly
Классический подход компиляции Rust в WebAssembly — при помощи таргета wasm32-unknown-unknown
(ref). Однако, такой способ накладывает множество ограничений. Например, не получится работать с std::fs
или потоками. Кроме того, не удастся скомпилировать подавляющее большинство C-зависимостей из-за необходимости линковки к libc.
Emscripten решает эту проблему, позволяя компилировать программы, изначально не предназначенные для работы в среде WebAssembly. Он реализует значительную часть функциональности libc, в том числе функционал чтения и записи файлов.
Хранение файлов в браузерной файловой системе OPFS
По умолчанию Emscripten использует in-memory файловую систему. Это позволяло протестировать работоспособность затеи, но не подходило для реальных сценариев.
До этого мы использовали lightning-fs, которая хранит файлы в IndexedDB — эта библиотека работала неплохо, но подружить с libgit2 её не удалось из-за специфики работы Emscripten. Он тоже поставляет бэкенд файловой системы, которая работает с IndexedDB и мы хотели использовать его. Однако его производительность оказалась значительно ниже, чем у lightning-fs.
Тогда мы перешли на WasmFS с бэкендом OPFS (Origin Private File System) — экспериментальную (на тот момент) файловую систему от Emscripten. Чтобы включить её в сборку, необходимо передать флаги -sWASMFS=1
и -pthreads
в emcc.
Какие были проблемы с WasmFS
Флаг -pthreads
вызвал целую цепочку проблем. Первое, с чем мы столкнулись — код перестал собираться, а линкер выдавал следующую ошибку:
wasm-ld: error: --shared-memory is disallowed by
.../release/deps/gramax_wasm.gramax_wasm.75e384114fc643b7-cgu.0.rcgu.o
because it was not compiled with 'atomics' or 'bulk-memory' features.
Чтобы обойти это, нам потребовалось включить соответствующие фича-флаги при помощи -Ctarget-feature=+atomics,+bulk-memory
, но это заставило нас:
Отказаться от pre-compiled std. По умолчанию Rust линкует предварительно собранную стандартную библиотеку, но в условиях этих фич пришлось её пересобирать. Флаг
-Zbuild-std
позволял это сделать.Перейти на nightly-тулчейн. Флаг
-ZBuild-std
считается нестабильным и rustc не позволяет его использовать на stable-ветке.-
Модифицировать билд-скрипта для libgit2. Сам libgit2 тоже было необходимо собрать с этими фича-флагами — для этого пришлось ещё раз пропатчить билд-скрипт git2-rs для сборки libgit2. Добавили флаги
-matomics
и-mbulk-memory
для WebAssembly-таргетов.if target.contains("wasm") { cfg.flag("-matomics").flag("-mbulk-memory"); }
Эти меры были необходимы для включения поддержки многопоточности — для этого флаг -pthreads
и создан. Под капотом она работает при помощи нескольких веб-воркеров, память которых шарится между собой при помощи SharedArrayBuffer, а каждый отдельный веб-воркер отвечает за один поток.
Браузеры достаточно серьёзно ограничивают использование SharedArrayBuffer и требуют установки следующих заголовков сервером:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
В документации это явно описано:
To use shared memory your document must be in a secure context and cross-origin isolated.
Из-за этого блокируются iframe и ресурсы из внешних источников — картинки или YouTube Embed, к примеру. В chromium-based браузерах доступен атрибут credentialless, который позволяет iframe’ам загружаться, однако в Firefox и Safari он не поддерживается.
Ограничения со стороны браузеров по CORS
Браузеры требуют CORS-заголовки для запросов к Git-хостингам (например, GitHub или GitLab). Изначально мы использовали оригинальный код из PoC для сетевых запросов из ранее описанного репозитория и CORS-прокси от IsomorphicGit. Они неплохо работали друг с другом.
В дальнейшем мы добавили возможность использования кастомных заголовков, коллбек прогресса запроса и обработку его отмены.
В такой связке, libgit2 не очень адекватно реагировал на прерывание запросов или их ошибки и вместо понятного сообщения выкидывал что-то на уровне bad packet length
. Мы немного исправили это, используя ответ сервера как сообщение ошибки.
Если вкратце: при ошибке JS вызывает Rust-функцию, которая устанавливает последнюю HTTP-ошибку. В конце выполнения функции (например, после фетча), проверяется наличие этой ошибки и оригинальная ошибка заменяется ответом сервера.
Вызов функций WebAssembly из JS
Как уже было описано выше, классический способ для компиляции Rust в WebAssembly — wasm32-unknown-unknown
. Он поддерживается крейтом wasm-bindgen
(ref), который умеет самостоятельно генерировать биндинги JS <-> Rust. На самом деле, для него существует ещё множество удобных инструментов для разработки на Rust, которых нет для Emscripten, но биндинги — самое критичное.
У Emscripten нет такой инфраструктуры для разработки и он ограничивает нас голым FFI. Доступны только примитивные типы данных в сигнатурах функций.
Экспортируемые функции выглядят подобным образом. Тут за пример взята функция из предыдущей главы:
#[no_mangle] // Атрибут #[no_mangle] запрещает компилятору изменять имя функции при сборке
pub unsafe extern "C" fn set_last_http_error(status: u16, body: *mut u8, body_len: usize) {
let body = Vec::from_raw_parts(body, body_len, body_len);
let body_str = String::from_utf8_lossy(&body);
// Часть реализации функции, не совсем относится примеру; Оставлена для полноты картины
LAST_HTTP_ERROR.with(move |err| {
info!(target: TAG, "set last_http_error: code: {}; message:\n{}", status, body_str);
err.lock().unwrap().replace(LastHttpError { status, res: body_str.to_string() });
})
}
Как можно заметить, в сигнатуре этой функции есть указатель на body
и его длина. Это подразумевает под собой, что в WebAssembly-модуле память под этим указателем уже будет выделена и заполнена каким-либо значением со стороны JS. Вызов функции выглядит так:
const setLastHttpError = async (status: number, body: string) => {
const [bodyLen, bodyPtr] = await str2ptr(body);
const fn = Module["_set_last_http_error"];
await fn(status, bodyPtr, bodyLen);
};
Функция str2ptr
занимается превращением JS-строки в C-строку, выглядит это так:
const encoder = new TextEncoder();
export const alloc = async (buffer: Uint8Array): Promise<number> => {
const ptr = await self.wasm._ralloc(buffer.byteLength);
const mem = new Uint8Array(self.wasm.wasmMemory.buffer);
mem.set(buffer, ptr);
return ptr;
};
export const str2ptr = async (s: string): Promise<[number, number]> => {
const s_bytes = encoder.encode(s);
const s_ptr = await alloc(s_bytes);
return [s_bytes.byteLength, s_ptr];
};
Функция _ralloc
находится на стороне Rust. Она не имеет особой логики и просто выделяет память на стороне WebAssembly.
#[no_mangle]
pub unsafe extern "C" fn ralloc(len: usize) -> *mut u8 {
std::alloc::alloc(Layout::from_size_align_unchecked(len, 4))
}
#[no_mangle]
pub unsafe extern "C" fn rfree(ptr: *mut c_void, len: usize) {
std::alloc::dealloc(ptr.cast(), Layout::from_size_align_unchecked(len, 4));
}
Важное уточнение — экспортируемые функции должны быть перечислены в флаге -sEXPORTED_FUNCTIONS=fn1,fn2
при сборке. После неё функции будут доступны для вызова из JS, но будут иметь префикс _
.
Сокращение бойлерплейт-кода для экспортируемых WebAssembly-функций
Описанный выше подход можно было бы назвать допустимым, если бы мы ограничивались лишь парой функций с простыми сигнатурами. Но нам нужно было несколько больше функционала, с более сложной структурой аргументов и возвращаемых значений.
И поскольку Emscripten не поддерживает wasm-bindgen
, мы решили написать свой макрос, умеющий превращать функции с красивыми сигнатурами, в FFI-совместимые. В конечном итоге выглядит это так (для примера взята функция diff
, структура её параметров приведена для полноты картины):
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct DiffConfig {
#[serde(default)]
pub compare: DiffCompareOptions,
#[serde(default = "default_use_merge_base")]
pub use_merge_base: bool,
pub renames: bool,
}
#[derive(Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct DiffTree2TreeInfo {
pub has_changes: bool,
pub added: usize,
pub deleted: usize,
pub files: Vec<DiffTree2TreeFile>,
}
// ---
#[em_bindgen(json)]
pub fn diff(repo_path: String, opts: DiffConfig) -> Result<DiffTree2TreeInfo> {
git::diff(Path::new(&repo_path), opts) // неймспейс git - внутренняя либа для работы с git; не git2-rs в чистом виде
}
export type DiffConfig = {
compare: DiffCompareOptions;
renames: boolean;
useMergeBase?: boolean;
};
export type DiffTree2TreeInfo = {
hasChanges: boolean;
added: number;
deleted: number;
files: DiffTree2TreeFile[];
};
export const diff = (args: Args & { opts: DiffConfig }) => call<DiffTree2TreeInfo>("diff", args);
В общих чертах это работает следующим образом:
-
Со стороны Rust. (ref) Макрос
#[em_bindgen]
генерирует FFI-совместимую функцию, которая оборачивает исходную. Если описывать в C-стиле, она выглядит так:size_t fn(size_t len, const uint8_t* ptr);
В качестве аргументов она принимает указатель на начало JSON-строки и её длину. В компанию к этой функции генерируется структура, описывающая аргументы изначальной функции — по её шаблону происходит парсинг.
Результат возвращается либо как массив байт, либо как JSON.
-
Со стороны JS. (ref) Функция
call
разная для каждой из платформы (веб, десктоп или докпортал). Но в нашем случае она сериализует аргументы в JSON-строку, которую затем копирует в память WebAssembly-модуля и передаёт указатель в функцию.На самом деле это устроено несколько сложнее, расскажу об этом далее.
Распределение задач по пулу потоков в WebAssembly
WebAssembly-модуль запускается в отдельном веб-воркере (будем называть его главным), чтобы не блокировать основной поток во время выполнения своих функций. Изначально этого было достаточно, однако при росте количества одновременных задач он стал узким местом, поскольку операции выполнялись последовательно.
Тогда мы решили воспользоваться преимуществами -pthreads
— создать пул потоков-воркеров, где главный поток занимается распределением задач, а исполнение — в одном из потоков пула.
static THREAD_POOL: LazyLock<ThreadPool> =
LazyLock::new(|| threadpool::Builder::new().num_threads(7).build());
static ATOMIC_CALLBACK_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
pub type JobCallbackId = usize;
pub fn run<F: FnOnce() -> Buffer + Send + Sync + 'static>(job: F) -> JobCallbackId {
let callback_id = ATOMIC_CALLBACK_ID_COUNTER.fetch_add(1, Ordering::SeqCst);
THREAD_POOL.execute(move || {
let buffer = job();
on_done(callback_id, buffer)
});
callback_id
}
fn on_done(callback_id: usize, buffer: Buffer) {
unsafe {
let ptr = buffer.boxed();
let script = format!("self.postMessage({{ callbackId: {}, ptr: {} }})", callback_id, ptr as usize);
let script_cstr = CString::new(script).unwrap().into_raw().cast::<u8>();
crate::ffi::emscripten_run_script(script_cstr);
}
}
Со стороны Rust реализация получилась простой и лаконичной — #[em_bindgen]
генерирует функцию, которая выглядит так:
#[serde(rename_all = "camelCase")]
struct DiffArgs {
repo_path: String,
opts: DiffConfig,
}
#[no_mangle]
unsafe extern "C" fn diff(len: usize, ptr: *const u8) -> JobCallbackId {
let send_ptr = ptr as usize;
crate::threading::run(move || {
let ptr = send_ptr as *mut u8;
let raw_data = Vec::from_raw_parts(ptr, len, len);
match serde_json::from_slice::<DiffArgs>(&raw_data) {
Ok(args) => {
let DiffArgs { repo_path, opts } = args;
let res: Result<DiffTree2TreeInfo> = {
git::diff(Path::new(&repo_path), opts)
};
let bytes = match res {
Ok(res) => Ok(serde_json::to_vec(&res).expect("unable to serialize")),
Err(err) => Err(err),
};
crate::Buffer::from(bytes)
}
Err(err) => {
let res = Err(err.to_string());
let bytes = match res {
Ok(res) => {
Ok(serde_json::to_vec::<String>(&res).expect("unable to serialize"))
}
Err(err) => Err(err),
};
crate::Buffer::from(bytes)
}
}
})
}
А вот со стороны JS было несколько сложнее: как минимум пришлось патчить JS-скрипт после сборки. Это было нужно для того, чтобы он вызывал postMessage
и уведомлял родительский веб-воркер (также со стороны JS) по завершению функции. Сделано это было достаточно грубо и при обновлении Emscripten иногда ломается, но в целом — работает.
Главный веб-воркер вызывает соответствующую функцию и сохраняет её идентификатор коллбека в callbacks
.
const callbacks: { [id: number]: WasmCallback } = {};
self.addEventListener("message", async (ev) => {
if (ev.data.type == "git-call") {
const id = await callGit(ev.data.command, ev.data.args);
callbacks[id] = { callbackId: ev.data.callbackId, command: ev.data.command, type: ev.data.type };
return;
}
});
После завершения операции вызывается on_done
из Rust-кода — он прокидывается до главного веб-воркера, который соотносит идентификатор, парсит результат и передаёт назад в основной поток страницы. Этот идентификатор нужен для резолва соответствующего промиса.
self.on_done = (innerCallbackId: number, ptr: number) => {
const { command, callbackId, type } = callbacks[innerCallbackId] || {};
delete callbacks[innerCallbackId];
if (!command) return;
const str_res = ptr2str(ptr);
if (!str_res) {
self.postMessage({ type, callbackId, ok: false, res: null });
return;
}
return self.postMessage({
type,
callbackId,
ok: str_res.ok,
res: str_res.buf ? JSON.parse(str_res.buf) : null,
});
};
Заключение
Перед нами стояла задача — добавить возможность работы с git-репозиториями. По началу мы использовали IsomorphicGit, а затем полностью перешли на libgit2, как в браузерном приложении, так и в остальных компонентах Gramax.

Выглядит это примерно так:
-
Пользователь добавляет любое git-хранилище, в данном случае — GitLab.
-
Вносит изменения, нажимает «Опубликовать» — происходит коммит и пуш.
Нажимет «Синхронизировать» — происходит пулл.
В этой статье описано только браузерное приложения для редактирования, но в Gramax есть еще портал документации и десктопное приложение на Tauri под Windows, macOS и Linux. Там тоже все не без сложностей, но об этом расскажем в следующих статьях. Конечно, если вам будет интересно.
Открыто, бесплатно, и с сообществом
Смотрите наш сайт — https://gram.ax
Вступайте в комьюнити — https://t.me/gramax_chat