Я написал сервер, фронтенд и расширение Chrome на Rust. Стоило ли оно того?

—

от автора

Я написал сервер, фронтенд и расширение 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

Поиск слов после паузы во вводе

signal_debounced для сигнала, use_debounce_fn для функции

Ограничение частоты вызовов

Throttle

Подгрузка слов при прокрутке

use_infinite_scroll

Настройки в хранилище браузера

Реактивная работа с хранилищем

Переключение темы

Управление цветовым режимом

Когда пользователь приближается к концу списка, 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 = 1opt-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 ГБ места на диске 😀 Особенно при активной разработке, когда постоянно что-то меняешь в общих крейтах.

ссылка на оригинал статьи https://habr.com/ru/articles/1104922/