Всё началось с бага, на который я убил вечер. В Tauri фронтенд вызывает Rust-команды так:
const user = await invoke("get_user", { userId: 42 });
Строка с именем команды, объект с аргументами, никакой проверки типов. У меня в Rust аргумент назывался user_id, и я честно передал user_id с фронта. В рантайме прилетело что-то вроде invalid args 'userId' for command 'get_user' — Tauri по умолчанию переименовывает snake_case-аргументы в camelCase. Это написано в документации, я это даже знал. Но знание, которое не проверяет компилятор, — не знание, а примета.
Дальше по классике: возвращаемый тип у invoke — any, а о переименованном поле в структуре фронт узнаёт уже от пользователей. Захотелось, чтобы всё это генерировалось из Rust-кода само.
Почему не готовые решения
ts-rs умеет экспортировать типы, но требует #[derive(TS)] на каждой структуре и ничего не знает про команды. tauri-specta закрывает и команды, но просит зарегистрировать хендлеры через свои макросы и живёт внутри кода приложения. Оба варианта рабочие, и если они вам подходят — берите их.
Мне хотелось другого: CLI, который натравливаешь на существующий проект и получаешь готовые файлы. Без аннотаций и без единой правки в Rust-коде. Так появился tauri-ts-generator. Вот что он делает:
#[derive(Serialize)]#[serde(rename_all = "camelCase")]pub struct User { pub id: u64, pub display_name: String,}#[tauri::command]async fn get_user(user_id: u64, state: State<'_, AppState>) -> Result<User, String> { // ...}
превращается в
// types.tsexport interface User { id: number; displayName: string;}// commands.tsexport async function getUser(userId: number): Promise<User> { return invoke<User>("get_user", { userId });}
State исчез из сигнатуры — его инжектит сам Tauri, фронт его не передаёт. Первый же баг-репорт в трекере был, кстати, ровно про это: если объявить State через type-алиас, он просачивался в TS-типы. Result<User, String> стал Promise<User>, ошибка и так прилетит как reject. А display_name стал displayName, потому что на структуре висит rename_all = "camelCase" — генератор смотрит на serde-атрибуты, а не на имена полей в коде.
Как это устроено внутри
Никакой магии с компилятором: генератор парсит исходники через syn. Сканер обходит src-tauri, парсер вытаскивает функции с #[tauri::command] и все структуры, енумы и алиасы, резолвер разруливает модули и use-алиасы, генератор пишет два файла. Примерно 12 тысяч строк Rust. Резолвер, кстати, в какой-то момент разросся до 1378 строк в одном файле, и его пришлось пилить на подмодули — модульная система Rust со всеми её pub use, реэкспортами и use crate as alias оказалась самой неблагодарной частью задачи.
А вот что пришлось научиться понимать про serde (это реальные коммиты, не теоретизирование):
-
#[serde(rename_all)]во всех восьми вариантах, плюс точечныйrenameна полях; -
tagged и untagged енумы:
#[serde(tag = "type", content = "data")]полностью меняют форму JSON; -
#[serde(flatten)]— в TS это intersection types; -
Option<Option<T>>, который serde схлопывает; -
#[serde(transparent)], newtype- и tuple-структуры; -
кейс-конверсия с акронимами: serde считает
HTTPServerдвумя словами, а моя наивная реализация приrename_all = "snake_case"выдавалаh_t_t_p_server.
Главный принцип, к которому я пришёл не сразу: генерировать надо не то, как выглядит Rust-код, а то, что serde реально отправит по проводу. Это разные вещи. TS-тип, который красиво повторяет структуру, но расходится с рантайм-JSON хотя бы в одном поле, хуже, чем any: он врёт с уверенным видом.
Война с макросами, или три релиза за один день
Дальше я упёрся в macro_rules!, который штампует десяток однотипных команд. Парсер исходников, естественно, видит макрос и не видит команд.
Ок, думаю, есть же cargo expand — разверну макросы и распарсю результат. Релизю 2.0.1: прогоняю тот же parse_commands по развёрнутому коду. Находит ноль команд.
Полез смотреть в сам expand-вывод (с чего, конечно, стоило начать). Оказалось, #[tauri::command] — процедурный макрос, и при развёртывании он съедает сам себя: в выводе остаётся голая функция без атрибута. А мой парсер фильтровал именно по атрибуту. Я искал маркер, который expand гарантированно уничтожает.
Зато рядом с каждой командой Tauri оставляет след: pub use __cmd__get_user;. Релизю 2.0.2: собираю эти __cmd__-маркеры из use-итемов и матчу обратно к функциям. Работает. Правда, теперь у меня два параллельных обходчика AST с почти одинаковой логикой.
В 2.0.3 объединил их в один walker и заодно обнаружил, что старый рекурсировал во вложенные mod ровно на один уровень, а в impl-блоки не заглядывал вообще. Команда внутри mod a { mod b { ... } } молча терялась. Восемь новых тестов пригвоздили контракт.
Все три релиза вышли 10 мая. Насыщенный был день.
Одну вещь я чинить не стал. Если макро-генерённая команда использует rename_all, это значение восстановить невозможно: код с переименованием генерируется на стороне потребителя, внутри generate_handler!, и в expand-выводе библиотеки его просто нет. Можно было городить эвристику, но я записал это в changelog как known limitation: нужен rename_all — пишите команду руками.
Windows, TOML и \U
Короткая, но любимая. Четыре теста падали только на Windows CI с паникой too few unicode value digits, expected unicode hexadecimal value. Тесты подставляли путь от tempdir в TOML-конфиг:
let config = format!(r#"types_file = "{}""#, dir.path().display());
На Windows путь — это C:\Users\runneradmin\.... А в TOML basic strings (двойные кавычки) бэкслеш — escape-символ, и \U означает начало юникод-эскейпа. Парсер ждёт восемь hex-цифр, получает sers\... и падает. Фикс — одинарные кавычки, literal strings, путь как есть. Один символ в кавычках, зелёный CI.
Тесты
Юнит-тесты на парсер и кейс-конверсию есть, но главную уверенность дают e2e: харнесс собирает во временной папке настоящий мини-проект с Cargo.toml и исходниками, запускает генератор целиком и проверяет выходные файлы. Сейчас тестов 352, и под каждый фикс из истории выше в репозитории лежит e2e, воспроизводящий проблему.
Релизы
Публикация на crates.io — через GitHub Actions по тегу. Локально cargo-release только бампает версию и ставит тег, токена registry на машине нет вообще. Паблиш идемпотентный, и это выстрадано. Релизный ран v2.0.0 опубликовал крейт и упал на следующем шаге, создании GitHub-релиза: 403, токену не хватило прав. А просто перезапустить нельзя — второй заход падал бы уже на «already exists». Отдельно отличился cargo search, через который CI проверял «крейт уже опубликован?»: однажды он вернул пустоту для крейта, который точно есть в индексе, и проверка оказалась хрупче того, от чего защищала. Теперь CI всегда пробует publish и разбирает ответ cargo: «already exists» — не ошибка, едем дальше.
Что в итоге
Генератор живёт на crates.io как tauri-ts-generator, сейчас версия 2.1.0. Внешних PR за время жизни проекта было два. Причём #[ts(optional)] вообще родился из обратной связи: пользователь попросил фичу в issue, а потом сам же прислал PR с доработкой генерации — prop?: T вместо prop: T | undefined. Второй PR принёс поддержку tauri::ipc::Channel<T>, так что стриминг из бэка на фронт теперь тоже типизирован. Чужой pull request в твой пет-проект — отдельное удовольствие.
Из честного: генератор парсит исходники, а не спрашивает компилятор, поэтому всегда найдётся способ его обмануть — алиас на алиас через три крейта, условная компиляция, макросы поверх макросов. Часть таких кейсов закрывает флаг use_cargo_expand, часть закрывать не планирую: предпочитаю простую модель с задокументированными ограничениями.
Код: https://github.com/Dudude-bit/tauri-codegen Крейт: https://crates.io/crates/tauri-ts-generator
Если у вас Tauri-проект и вы тоже устали от строковых invoke — попробуйте. Споткнётся на вашем коде — несите репро в issues.
ссылка на оригинал статьи https://habr.com/ru/articles/1048060/