Я написал сервер, фронтенд и расширение Chrome на Rust. Стоило ли оно того?
Я хотел сделать сервис для изучения слов, в котором один набор карточек можно использовать в разных упражнениях. В Anki мне нравился сам подход к повторению, но хотелось добавить другие режимы, например заполнение пропусков. Заодно решил проверить, хватит ли мне Rust для сервера, веб-интерфейса и расширения Chrome.
Rust уже стал моим любимым языком для пет-проектов, а в основной работе я привык к C# и .NET. Моё знакомство с Rust началось с неудачной попытки подключить Xbox контроллер к Linux машине. После долгого выбора между Rust и C++ я остановился на первом варианте. После пары достаточно низкоуровневых проектов на Rust я решил попробовать его в родной для себя сфере - веб-разработке. Leptos я выбрал довольно случайно, по хорошему отзыву. К концу лета 2026 года у меня работал MVP с карточками, личными и публичными коллекциями, обогащением слов через ИИ и браузерным расширением. Упражнения с пропусками остались в планах.
Один язык, несколько приложений
Пользователь выделяет слово на странице и сохраняет его из расширения вместе с адресом источника. Если включено обогащение, сервер запрашивает перевод и примеры. Позже это же слово можно повторить в веб-приложении.
Часть | Среда выполнения | Как получает данные |
|---|---|---|
HTTP API на Axum | Нативный серверный процесс | PostgreSQL через SQLx, внешние API |
Лендинг на Leptos | Серверный рендеринг и гидратация в браузере | Серверные функции Leptos |
PWA на Leptos | WebAssembly в браузере | HTTP API |
Расширение Chrome | WebAssembly и JavaScript в окружении расширения | HTTP API и взаимодействие со страницей |
Два режима Leptos в одном проекте
Словарь работает как PWA. Его файлы лежат на Cloudflare Pages. Браузерная сборка Leptos формирует интерфейс на клиенте и обращается к HTTP API на Axum.
На лендинге я впервые попробовал гидратацию. Сервер рендерит HTML, а в браузере WASM-сборка подключает к существующей разметке обработчики и реактивные обновления. DOM после разбора HTML браузером должен совпасть со структурой, на которую рассчитывает клиентский код. Гидратация в Leptos. Недавно читал, что режим Islands довели до ума. Если дойдут руки, сделаю кейс и поделюсь результатом на Хабре.
На странице публичной коллекции серверная функция Leptos загружает слова для начального HTML. При переходе на такую страницу внутри сайта браузер вызывает ту же функцию по HTTP и получает сериализованный результат. Серверную часть лендинга и основной API я размещал на Fly.io из-за очень выгодных тарифов (об этом ниже), легкого docker деплоя и встроенной grafana.
К слову, Axum и leptos требуют очень мало RAM и практически не нагружают машину в момент простоя. Так что два сервиса на fly.io обходились мне бесплатно из-за льготы до $5.
Как это ощущается в интерфейсе
В словаре постоянно меняются небольшие части состояния. Ввожу текст в строку поиска, и её значение обновляется сразу. Для сетевого запроса компонент читает другой сигнал, signal_debounced, с задержкой 250 мс. Когда приходит ответ, меняется список слов. У каждой новой выборки свой номер. Если ответ на предыдущий запрос пришёл позже, компонент отбрасывает его.
Точечные обновления DOM убедили меня продолжить эксперимент с Leptos. Я могу связать поле ввода с одним сигналом, а загрузку списка с другим. О скорости я сужу по отклику самого словаря. Как Leptos отслеживает зависимости.
leptos-use и повседневные задачи интерфейса
Во фронтенд-части мне не хватало готовых наборов UI-компонентов. С поведением интерфейса было проще. Многое нашлось в leptos-use.
Задача | Средство leptos-use |
|---|---|
Поиск слов после паузы во вводе |
|
Ограничение частоты вызовов | |
Подгрузка слов при прокрутке | |
Настройки в хранилище браузера | Реактивная работа с хранилищем |
Переключение темы | Управление цветовым режимом |
Когда пользователь приближается к концу списка, use_infinite_scroll вызывает мой обработчик. Если сейчас ничего не загружается и есть ещё слова, обработчик запрашивает следующую страницу. Размер страницы у меня 50 слов. Так работает и общий словарь, и просмотр слов одной коллекции. Для следующей страницы я беру ту же поисковую строку после debounce, что и для первой. Иначе при быстром вводе можно запросить продолжение списка уже с другим фильтром.
Расширение Chrome и граница с JavaScript
В веб-приложении JavaScript понадобился в основном для CAPTCHA. В расширении чаще приходилось связывать Rust с функциями браузера. Например, при открытии popup можно автоматически подставить URL активной вкладки в поле источника. Из JavaScript для этого вызывают chrome.tabs.query({ active: true, currentWindow: true }). В Rust у меня получилась такая привязка.
use wasm_bindgen::{JsValue, prelude::wasm_bindgen};
use wasm_bindgen_futures::JsFuture;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = ["chrome", "tabs"], js_name = query)]
fn tabs_query(query_info: &JsValue) -> js_sys::Promise;
}
async fn get_current_url() -> Option<String> {
let query = js_sys::Object::new();
js_sys::Reflect::set(&query, &"active".into(), &JsValue::TRUE).ok()?;
js_sys::Reflect::set(&query, &"currentWindow".into(), &JsValue::TRUE).ok()?;
let result = JsFuture::from(tabs_query(&query)).await.ok()?;
let tabs = js_sys::Array::from(&result);
let tab = tabs.get(0);
let url = js_sys::Reflect::get(&tab, &"url".into()).ok()?;
url.as_string()
}
Здесь wasm-bindgen связывает Rust-функцию с chrome.tabs.query. Параметры я собираю в JS-объект, дожидаюсь Promise через JsFuture, достаю вкладку из массива и читаю поле url. Именно преобразования через js_sys были для меня самой неудобной частью расширения. Объекты JavaScript в js-sys.
Для чтения URL у расширения в манифесте есть разрешение activeTab. Оно даёт временный доступ к вкладке при действии пользователя. Если запрос отклонён или url не содержит строку, функция возвращает None и popup пропускает автоподстановку. При сохранении слова он отдельно проверяет, что источник заполнен. Разрешения Chrome Tabs API.
Контракт API как обычная зависимость
Когда один API обслуживает PWA и расширение, изменение формата запроса касается обоих клиентов. Типы запросов и ответов я вынес в общий крейт. Вот сокращённый CreateWord для сохранения слова. В нём оставил три обязательных поля и одно необязательное.
use serde::{Deserialize, Serialize};
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct CreateWord {
pub value: String,
pub source: String,
pub notes: String,
#[serde(default)]
pub example_translation: Option<String>,
}
Сервер десериализует запрос в тот же CreateWord, который собирает расширение. Похожее решение на .NET доступно, если и сервер, и клиент могут подключить одну сборку. Например, ASP.NET Core и клиент на Blazor WebAssembly могли бы использовать общий record. Атрибуты задают те же имена полей в JSON. Blazor WebAssembly, имена свойств в System.Text.Json.
using System.Text.Json.Serialization;
public sealed record CreateWord(
[property: JsonPropertyName("value")] string Value,
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("notes")] string Notes,
[property: JsonPropertyName("example_translation")] string? ExampleTranslation = null);
Для React-приложения на TypeScript и обычного JS-расширения общим остаётся формат JSON по HTTP. Для TypeScript можно сгенерировать типы из OpenAPI. Прямая зависимость от библиотеки с C# DTO требует среды .NET. В моём проекте оба клиента собираются из Rust и подключают крейт с DTO напрямую.
При переименовании source сборка укажет на старое имя там, где клиент явно задаёт это поле. Добавление поля даёт другой результат. В popup я заполняю часть структуры через ..Default::default(), так что новое поле может получить значение по умолчанию. В C# после добавления обязательного параметра без значения по умолчанию придётся поправить вызовы new CreateWord(...), где этого аргумента нет.
OpenAPI через Cargo feature
Описание схемы OpenAPI нужно серверу. В крейте с DTO зависимость utoipa опциональна, а cfg_attr на структуре включает derive только при сборке с одноимённой Cargo feature.
[features]
utoipa = ["dep:utoipa"]
[dependencies]
serde = { version = "1", features = ["derive"] }
utoipa = { version = "5", optional = true }
Сервер запрашивает features = ["utoipa"], PWA и расширению для сериализации хватает Serde. Cargo может объединять features одной зависимости, запрошенные разными пакетами. В моём проекте сервер и WASM-клиенты собираются отдельно, поэтому серверный запрос на utoipa не попадает в сборку PWA. Derive ToSchema, объединение features в Cargo.
Что общий крейт не проверит
Установленное расширение может обновиться позже сервера и продолжать отправлять прежний JSON. Переименование поля в JSON или новое правило валидации меняют поведение API даже при успешной сборке свежих исходников.
Axum и SQLx на сервере
С Axum и SQLx я работал примерно в том же темпе, к которому привык в .NET. Правда, подход к подключению зависимостей здесь отличается. В ASP.NET Core многие библиотеки можно подключить и настроить буквально одной строкой через DI. В Rust чаще приходится явно создавать нужные компоненты и передавать их через состояние приложения. При этом макросы избавляют от значительной части шаблонного кода. Получается забавный контраст: зависимости подключаются явно, но внутри библиотек хватает своей магии на этапе компиляции.
Авторизация в сигнатуре обработчика
В проекте используются JWT и refresh-токены. Для добавления слова обработчик Axum принимает VerifiedUser. Экстрактор проверяет токен, находит пользователя в базе и отклоняет запрос для удалённой учётной записи или неподтверждённой почты. В сигнатуре ниже я сократил имя сервиса.
use std::sync::Arc;
use axum::{Json, extract::State, http::StatusCode};
async fn add_word(
VerifiedUser(user_id): VerifiedUser,
State(service): State<Arc<WordService>>,
Json(payload): Json<CreateWord>,
) -> Result<(StatusCode, Json<WordCreatedId>), ApiError> {
let word_id = service.create_new_word(payload, user_id).await?;
service.create_card(&word_id, user_id).await?;
Ok((StatusCode::CREATED, Json(WordCreatedId(word_id.0))))
}
VerifiedUser реализует FromRequestParts и оставляет тело запроса для Json, поэтому Json стоит последним. Если экстрактор отклонит запрос, обработчик не вызовется. Доступ к конкретной коллекции или слову проверяется отдельно. Экстракторы Axum.
SQL с проверкой при компиляции
В SQLx меня привлекли макросы, с которыми можно писать SQL вручную и проверять запрос ещё при сборке. Когда пользователь отвечает на карточку, сервер увеличивает счётчик его активной сессии. Для PostgreSQL этот фрагмент внутри открытой транзакции tx выглядит так.
sqlx::query!(
"UPDATE review_sessions
SET answered_count = answered_count + 1
WHERE id = $1 AND user_id = $2 AND status = 'active'",
session_id,
user_id.as_uuid(),
)
.execute(&mut *tx)
.await?;
В .NET ту же операцию можно выполнить через Npgsql. Пусть tx уже открыта на соединении connection, sessionId имеет тип long, а userId имеет тип Guid.
await using var command = new NpgsqlCommand("""
UPDATE review_sessions
SET answered_count = answered_count + 1
WHERE id = $1 AND user_id = $2 AND status = 'active'
""", connection, tx);
command.Parameters.Add(new NpgsqlParameter { Value = sessionId });
command.Parameters.Add(new NpgsqlParameter { Value = userId });
await command.ExecuteNonQueryAsync();
Оба запроса используют позиционные параметры PostgreSQL. Значение для $1 передаётся первым, для $2 вторым. Если написать answer_count вместо answered_count, C#-код с такой строкой соберётся. Об ошибке сообщит PostgreSQL при выполнении команды, например в интеграционном тесте. Если SQLx сверяет запрос с актуальной схемой, у query! та же опечатка остановит cargo check. Параметры Npgsql.
Для query! и query_as! сборке нужна база с подходящей схемой либо заранее подготовленные метаданные в .sqlx. В offline-режиме проверка опирается на эти файлы, поэтому после изменения запросов и схемы их нужно обновлять. Сравнение с Npgsql касается SQL, который я пишу сам. В LINQ-запросе для EF Core компилятор C# проверял бы обращения к свойствам модели, а SQL построил бы провайдер. Проверка запросов в SQLx, LINQ в EF Core.
Один ответ, несколько связанных изменений
Запрос может соответствовать схеме и всё-таки обновлять счётчик отдельно от истории. Если запись в истории уже зафиксирована, а обновление сессии завершилось ошибкой, в словаре окажется ответ, которого счётчик не учёл. Из-за таких зависимостей я менял структуру API и бизнес-логику.
В submit_session_answer новое состояние карточки рассчитывается с помощью FSRS до начала транзакции. Внутри одной транзакции SQLx я обновляю параметры карточки и дату следующего повторения, добавляю запись в историю и перевожу новое слово в состояние изучения. В сессии сохраняются оценка и время ответа, увеличивается счётчик. Если в сессии не осталось карточек без ответа, она завершается. Все эти изменения фиксируются одним commit.
При обновлении элемента сессии в WHERE есть условие answered_at IS NULL. Если rows_affected() возвращает ноль, функция выходит с ошибкой до commit. Уже выполненные в этой транзакции изменения карточки и истории откатываются. Транзакции SQLx.
В интеграционном тесте в сессии два слова, чтобы после ответа на первое она была ещё активна. Тест дважды подряд отправляет один и тот же ответ и ожидает сначала HTTP 200, затем 400. До и после второго запроса он сравнивает число записей истории, счётчик сессии, оценку и длительность ответа, даты повторений и число забываний карточки.
Какой код остаётся только на сервере
Расписание повторений я оставил на сервере. Клиент передаёт оценку ответа, а новое состояние карточки и дату следующего повторения сервер рассчитывает с помощью крейта fsrs. Реализация FSRS для Rust.
Для обогащения слов сервер обращается к OpenAI API. Я использовал недорогую GPT-модель для перевода и примеров. Лимит на обогащения проверяет сервер, поэтому он действует и в PWA, и в расширении. В продакшене данные хранятся в PostgreSQL. При моей нагрузке потребности в Redis не возникло.
От семи до пятнадцати минут на release-сборку
На MacBook с M4 Max у меня уходило от семи до пятнадцати минут на release-сборку после правок. Когда я настроил общий каталог артефактов Cargo, время обычно держалось около семи минут.
У сервера свой workspace с пакетами приложения и домена. У PWA и расширения отдельные манифесты, общего корневого workspace для них нет. В Makefile я задаю абсолютный путь к каталогу, чтобы Cargo использовал одно и то же место при запусках из разных директорий.
ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
export CARGO_TARGET_DIR := $(ROOT)/target
Для PWA Makefile запускает trunk build --release, для расширения использует wasm-pack build --target web --release. У wasm-pack параметр --target web задаёт формат JS-модуля, который загрузит WASM в браузере. Команда сборки wasm-pack.
Release-профиль у обоих клиентов одинаковый.
[profile.release]
panic = "abort"
codegen-units = 1
opt-level = "z"
strip = "symbols"
lto = true
С opt-level = "z" приоритетом становится размер WASM. lto = true включает оптимизацию между крейтами при компоновке, а codegen-units = 1 ограничивает параллельную генерацию кода внутри отдельного крейта. У расширения после компиляции настроен ещё один проход wasm-opt с -Oz. Профили Cargo.
Общий каталог позволяет повторно использовать совместимые артефакты. У нативного сервера и WASM-клиентов разные цели компиляции. На переиспользование также влияют профиль и включённые features. Кеш сборки Cargo.
Clippy и rustfmt
В корневом Makefile задачи fmt-check и clippy обходят манифесты по отдельности. Clippy запускается с -D warnings. Некоторые его правила находят лишнее копирование или ненужное выделение памяти. Каталог Clippy.
Из корня репозитория можно проверить форматирование всех пакетов, собрать серверный workspace в режиме check и проверить PWA для WASM.
make fmt-check
(cd saas/server && cargo check --workspace)
cargo check --manifest-path clients/web/Cargo.toml --target wasm32-unknown-unknown
Для расширения ту же цель WASM указываю при запуске Clippy из каталога, где лежит его Cargo.toml.
cargo clippy --target wasm32-unknown-unknown -- -D warnings
Тесты и работающий продукт
В тестах я проверяю доменную и бизнес-логику, а для клиентских сценариев использую Playwright. В моей стартовой конфигурации были заготовки E2E-тестов. Один из сценариев для словаря создаёт слово через интерфейс, меняет перевод и удаляет слово.
Unit тестирование выполнял через rstest и моки. Тут нет интересных кейсов, за исключением PhantomData, чтобы сохранить параметр обобщённого типа в структуре, которая непосредственно не хранит значение этого типа.
Для Prometheus я собирал прежде всего события продукта. Меня интересовали регистрации, повторения, возвращения пользователей, новые слова и использование лимитов на обогащение. У обращений к модели счётчик различает исходы, в том числе отказ из-за исчерпанной квоты. Отдельно стоит отметить удобство регистрации Prometheus. Сам счетчик мы обновляем через макрос в любой части кода, а эндпоинт метрик можно удобно вынести на отдельный порт.
pub async fn spawn(port: u16, handle: PrometheusHandle) -> anyhow::Result<()> {
let app = Router::new().route("/metrics", get(move || async move { handle.render() }));
let addr = format!("[::]:{port}");
let listener = tokio::net::TcpListener::bind(&addr).await?;
trace!("private metrics server on {addr}");
tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app).await {
tracing::error!(error = %e, "metrics server crashed");
}
});
Ok(())
}
Что я вынес из проекта
Стоит ли выбирать Rust для всего стека нового проекта? Вопрос спорный. Если веб-приложение не сильно зависит от сторонних JavaScript-компонентов, я бы скорее ответил да. А если такие интеграции нужны, придётся разбираться с JavaScript API и связывать их с Rust-кодом.
Для меня главными преимуществами оказались общие контракты, серверные функции Leptos и возможность гибко настраивать сборку. Отдельно понравилось развёртывание: сервер можно запустить на Fly.io, а статическую WASM-сборку PWA разместить на Cloudflare Pages.
В следующей статье хочу рассказать об использовании WASM-плагинов в системе на Rust. Сейчас собираю материал и буду рад почитать о похожем опыте в комментариях.
P.S. И да, чертовски важно настроить общую директорию для артефактов сборки Cargo. Иначе несколько независимых workspace запросто могут сожрать 100 ГБ места на диске :D Особенно при активной разработке, когда постоянно что-то меняешь в общих крейтах.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.