Encore зародился как фреймворк на Go, и на Go там были написаны среда выполнения, интерфейс командной строки (CLI), парсер и компилятор. Когда мы решили поддерживать TypeScript, логичнее всего было бы написать на TypeScript и среду выполнения тоже, либо достроить к среде выполнения Go какой‑нибудь мостик. Но в итоге пришли к тому, что написали новую среду выполнения с нуля на Rust. Читать далее
Encore зародился как фреймворк на Go, и на Go там были написаны среда выполнения, интерфейс командной строки (CLI), парсер и компилятор. Когда мы решили поддерживать TypeScript, логичнее всего было бы написать на TypeScript и среду выполнения тоже, либо достроить к среде выполнения Go какой‑нибудь мостик. Но в итоге пришли к тому, что написали новую среду выполнения с нуля на Rust.
Конечно, многое выяснилось уже при работе с прототипом вспомогательного процесса (sidecar) на Go, но у нас было и ещё две причины взяться за этот проект — подробнее о них ниже. Во‑первых, мы планировали в будущем поддерживать наш продукт Encore на дополнительных языках.
К тому же, мы уже видели такие проекты как Prisma и Pydantic, где ядро на Rust успешно применяют с опорой на привязки к Node.js и Python соответственно. Мы собирались один раз написать основную логику на Rust, а затем отдельно привязывать к среде выполнения на каждом конкретном языке. Так нам не пришлось бы заново реализовывать обработку инфраструктуры для каждого добавляемого нами языка. Во‑вторых, Node.js в основе своей однопоточный. Поэтому удобно перенести на Rust всё, что не относится к бизнес‑логике ‑жизненный цикл HTTP‑запросов, управление соединениями с базой данных, публикация/подписка, трассировка — будет в полностью многопоточном режиме выполняться на tokio. Такой выигрыш в производительности на чистом Node.js недостижим.
Прошло два года, за которые было написано 67 000 строк кода. Теперь среда выполнения обрабатывает весь жизненный цикл HTTP‑запроса (маршрутизация, синтаксический разбор и валидация запроса, сериализация отклика), а также сбор соединений с базой данных в пул, запросы к базе данных. Также в этом коде организована публикация/подписка в масштабах трёх облачных провайдеров, распределённая трассировка, сбор метрик, хранение объектов, кэширование. Дополнительно ко всему этому имеется API‑шлюз, поддерживаемый на основе Pingora. Бизнес‑логика вашего приложения выполняется в коде на TypeScript, а всё что ниже — на Rust. В этом посте разобраны те решения, которые привели нас в сегодняшний день, какие неочевидные проблемы при этом возникли, и что мы предпочли бы сделать иначе.
Почему бы просто не расширить среду выполнения, написанную на GoСреда выполнения для Go хорошо работала с приложениями на Go и работает до сих пор. Она компилирует код в двоичный файл приложения и обрабатывает инфраструктурные проблемы на уровне фреймворка. Самый очевидный способ поддержки TypeScript виделся таким: запускать среду выполнения Go как вспомогательный процесс бок о бок с Node.js, а коммуникацию между ними организовать через межпроцессные вызовы (IPC).
Мы смоделировали все это в прототипе — и увидели, как быстро накапливаются накладные расходы и задержки, возникающие при сериализации каждого запроса к базе данных, каждого сообщения публикации/подписки, а также при трассировке событий через границы процессов. Отдельно взятый запрос к API, затрагивающий базу данных и публикующий событие, пересекает границы процессов в ходе IPC шесть‑семь раз. При этом бенчмарки показывают, что вспомогательный процесс добавляет 2–4 мс издержек на каждый запрос. Эти издержки возникают просто из‑за сериализации и переключения контекста, ещё до того, как мы успеваем выполнить какую‑то полезную работу.
Ещё была эксплуатационная проблема. Если у нас два процесса, то нужно наблюдать за двумя сущностями, причём, оба этих процесса могут отказать независимо друг от друга, после чего потребуется сверять два набора логов. При локальной разработке с этим можно справиться, но в продакшене с участием десятков сервисов режимы отказов растут как грибы после дождя.
Таким образом, среде выполнения нужно найти место в том же процессе, где находится и цикл событий Node.js. Для этого её потребовалось бы либо написать на C/C++, с привязками к N‑API, либо на Rust, воспользовавшись napi‑rs. Rust гарантирует безопасность в той же степени, что и среда выполнения Go (безопасность памяти, безопасность потоков, отсутствие гонок данных), а также доступ к асинхронной экосистеме (tokio). Tokio позволяет обрабатывать тысячи конкурентных соединений, не блокируя цикл событий Node.js.
Первые 10 000 строкПоддержку TypeScript мы разрабатывали шаг за шагом в течение нескольких месяцев, это была внутрикорпоративная работа, охватывавшая множество пул‑реквестов. В публичный релиз (#1073) вошли сразу три крейта Rust: основная среда выполнения, привязки JavaScript NAPI и парсер TypeScript. Фреймворк работает лишь при наличии всех трёх этих компонентов, поэтому релиз должен быть атомарным, даже если разработка атомарной не была.
Основная среда выполнения (runtimes/core) структурно представляет собой набор менеджеров, каждый из которых отвечает за тот или иной аспект инфраструктуры:
pub struct Runtime {
api: api::Manager, // Жизненный цикл HTTP, маршрутизация, авторизация
sqldb: sqldb::Manager, // Соединения с базой данных, пулинг, выполнение запросов
pubsub: pubsub::Manager, // Публикация топиков и подписка на них
objects: objects::Manager, // Хранилище объектов (S3, GCS)
metrics: metrics::Manager, // Сбор и экспорт
secrets: secrets::Manager, // Извлечение секретов
// ...
}Каждый менеджер лениво инициализируется на основе двух отдельных конфигураций протокола protobuf. В первой указаны метаданные приложения, описывающие систему как таковую. Их во время компиляции генерирует парсер TypeScript, читающий код вашего приложения и извлекающий инфраструктурные объявления:
// Метаданные приложения — полное описание системы.
// Генерируются во время компиляции парсером TypeScript/Go.
message Data {
string module_path = 1;
repeated Service svcs = 5;
optional AuthHandler auth_handler = 6;
repeated CronJob cron_jobs = 7;
repeated PubSubTopic pubsub_topics = 9;
repeated CacheCluster cache_clusters = 11;
repeated SQLDatabase sql_databases = 14;
repeated Gateway gateways = 15;
repeated Bucket buckets = 17;
// ...
}Вторая — это конфигурация среды выполнения, из которой среда узнаёт, как именно выполняться. Здесь указано, какую именно реализацию облачного провайдера использовать для публикации каждого топика/подписки на него, как аутентифицироваться, где именно расположен каждый кластер базы данных. Также здесь описано обнаружение сервисов для выполнения межсервисных вызовов и указаны настройки наблюдаемости. Эта информация генерируется во время развёртывания и зависит от целевой среды/платформы:
// Конфигурация среды выполнения — как именно каждая среда конфигурируется для времени выполнения.
// Генерируется во время развёртывания.
message RuntimeConfig {
Environment environment = 1; // облачный провайдер, тип среды (разработка/продакшен/тестирование)
Infrastructure infra = 2; // кластеры SQL, публикация/подписка, Redis, секреты, корзины
Deployment deployment = 3; // обнаружение сервисов, методы аутентификации, наблюдаемость
}Это важное разделение, поскольку одни и те же метаданные приложения можно развёртывать в самых разных окружениях (разработка на локальной машине с применением NSQ и Docker Postgres, продакшен с применением AWS SNS/SQS и RDS) — для этого нужно просто поменять одну конфигурацию среды выполнения на другую. Не меняется ни код приложения, ни описывающие его метаданные.
Как научить Rust говорить с JavaScript (и получать ответы)Node.js NAPI (N‑API) спроектирован для вызовов, поступающих из JavaScript в нативный код: вы регистрируете функцию, JavaScript её вызывает, вы возвращаете значение. Сложнее выполнять вызовы из нативного кода в JavaScript, а именно это и потребуется сделать, когда прибывает сообщение в рамках публикации/подписки, и это сообщение впоследствии понадобится направить обработчику TypeScript. Другой пример — когда HTTP‑запросу требуется вызвать функцию конечной точки на TypeScript.
В NAPI для этого предоставляются потокобезопасные функции, а napi‑rs красиво их обёртывает. Проблема в том, что стандартная абстракция поддерживает лишь отправку аргументов в JavaScript, но не поддерживает захват возвращаемого значения. Когда обработчик сообщений, занимающийся публикацией и подпиской, заканчивает очередную задачу, нам необходимо знать, удачно или неудачно завершилась эта операция. Таким образом, мы сможем отреагировать на это сообщение как ack или nack. Когда обработчик конечной точки API возвращает отклик, необходимо отправить этот отклик обратно к Rust. Rust его сериализует и отправит далее по сети.
Мы сделали форк функции ThreadSafeFunction из napi‑rs , чтобы можно было вручную вызывать функцию JavaScript и захватывать её возвращаемое значение:
// Форк функции threadsafe_function из napi-rs, позволяющий вызывать
// функцию JS вручную, а не просто возвращать аргументы.
// Так мы получаем возможность пользоваться возвращаемым значением этой функции.
pub struct ThreadSafeCallContext<T: 'static> {
pub env: Env,
pub value: T,
pub callback: Option<JsFunction>,
}Другая тонкость связана с промисами. Обработчики конечных точек TypeScript — это асинхронные функции, возвращающие промисы. Когда мы делаем вызов в код JavaScript и получаем в ответ значение, необходимо определить, не промис ли это. Если это промис, то нужно подцепить обратный вызов.then(), который будет разрешаться обратно в Rust через канал tokio. Так мы перекидываем мостик между асинхронной моделью JavaScript и Rust:
// Определяем, является ли промисом возвращаемое значение JS, и если так —
// ожидаем выполнения обещания, подцепляя .then(), который разрешается в канал Rust
fn await_promise(env: &Env, value: JsUnknown) -> Result<()> {
if value.is_promise()? {
// Сцепляем .then() с обратным вызовом, отправляющим результат обратно на сторону Rust
// через одноразовый канал tokio
}
}В такой конфигурации между Node.js и Rust складывается символическое отношение, которое отличается от более традиционного хозяин/гость. Node.js (или Bun, также поддерживаемый в данном контексте) запускает процесс и импортирует библиотеку Encore как нативный модуль. Затем библиотека принимается за решение инфраструктурных задач: это маршрутизация, соединения с базой данных, трассировка, публикация/подписка. Node.js владеет жизненным циклом процесса, тогда как Rust владеет инфраструктурным уровнем.
Из‑за этого возникает интересный пограничный случай, о котором ранее рассказывал Фредрик: оказывается, футуры Rust можно отбрасывать в любой момент (например, когда истекает время на обработку запроса Cloud Run, и из‑за этого соединение закрывается), но обработчик JavaScript продолжает работать в цикле событий Node.js, и отменить это нельзя. На стороне Rust мы никогда не достигнем request_span_end, в результате чего в трассировке не будет учтён отрезок самого верхнего уровня (root span). Исправить это можно при помощи CancellationGuard, обнаруживающего, когда именно отброшена футура, и порождающего свободную задачу tokio, которая будет дожидаться, пока обработчик JavaScript завершит работу:
/// Это сторожевое условие при отмене порождает обработчик, помещаемый в фоновую задачу.
/// Так гарантируется, что всегда будет выдаваться `request_span_end`. На нормальном пути выполнения
/// (обработчик завершает задачу до того, как произойдёт отмена) это операция бездействия (no-op).
struct CancellationGuard<'a> {
call: &'a mut HandlerCall,
info: Option<CancellationGuardInfo>,
}На нормальном пути выполнения, где обработчик успевает справиться с задачей до её отмены, сторожевой оператор ничего не делает. Когда футура отбрасывается прямо в процессе выполнения, сторожевая реализация Drop принимает на себя владение незавершённым вызовом HandlerCall и порождает его как фоновую задачу. В таком случае можно чисто завершить работу на стороне JavaScript и корректно закрыть участок трассировки.
Приложения Encore состоят из множества сервисов, обменивающихся информацией по сети. В производственной среде шлюз маршрутизирует внешние запросы к тому сервису, к которому нужно, а также обрабатывает аутентификацию, CORS и валидирует запросы. Типичный подход заключается в том, чтобы выполнять это в отдельном процессе, например, nginx или envoy, расположенном перед вашими сервисами. Но мы встроили его непосредственно в среду выполнения.
Для этого мы воспользовались Pingora, опенсорсной библиотекой HTTP‑прокси от Cloudflare. Она образовывала отдельный слой, служивший нам шлюзом. Pingora предназначена для использования именно в качестве библиотеки, а не в качестве самостоятельного двоичного файла — именно это нам и требовалось. Шлюз реализует типаж ProxyHttp библиотеки Pingora и подключается к тому же процессу, что и остальная среда выполнения:
pub struct Gateway {
inner: Arc<Inner>,
}
struct Inner {
service_registry: Arc<ServiceRegistry>,
router: router::Router,
cors_config: CorsHeadersConfig,
// Пуш-уведомления о публикации/подписке, опосредованные через шлюз
proxied_push_subs: HashMap<String, ProxiedPushSub>,
// ...
}Одной и той же памятью совместно пользуются шлюз, система авторизации, реестр сервисов и сборщик трасс. В процессе сериализации никак не разграничиваются этапы «прокси счёл, что этот запрос прошёл аутентификацию» и «запустился обработчик конечной точки». Результатом пройденной аутентификации является структура Arc'd, передаваемая непосредственно обработчику.
Вот в чём ключевое преимущество такого вутрипроцессного подхода: определяемые пользователем обработчики аутентификации, написанные на TypeScript, могут выполняться прямо внутри шлюза Pingora обращается с вызовом в Node.js, чтобы сработал обработчик аутентификации, в ответ получает результат, и запрос продолжает движение через шлюз, уже с прикреплённым к нему контекстом аутентификации. Для этого могла бы потребоваться сериализация и межпроцессная коммуникация с применением отдельного прокси‑процесса.
Pingora также обеспечивает пулинг соединений в направлении вышестоящих сервисов, поддержку HTTP/2 и аккуратный дренаж соединений (connection draining) на этапе развёртывания — не имея такой библиотеки, нам пришлось бы всё это либо строить самостоятельно, либо прикручивать извне.
Абстрагирование трёх облачных провайдеров и никакого ада дженериковСистема публикации/подписки должна идентично работать как в NSQ (локальная разработка), так и в GCP Pub/Sub, и AWS SNS+SQS. На Rust это было бы проще всего добиться, активно задействуя дженерики. Но в таком случае вариант, выбранный провайдером, протёк бы в сигнатуры всех типов, имеющихся в базе кода. Но мы поступим иначе и воспользуемся объектами‑типажами:
trait Cluster: Debug + Send + Sync {
fn topic(&self, cfg: &PubSubTopic, publisher_id: xid::Id)
-> Arc<dyn Topic + 'static>;
fn subscription(&self, cfg: &PubSubSubscription, meta: &Subscription)
-> Arc<dyn Subscription + 'static>;
}
trait Topic: Debug + Send + Sync {
fn publish(&self, msg: MessageData, ordering_key: Option<String>)
-> Pin<Box<dyn Future<Output = Result<MessageId>> + Send + '_>>;
}
trait Subscription: Debug + Send + Sync {
fn subscribe(&self, handler: Arc<SubHandler>)
-> Pin<Box<dyn Future<Output = APIResult<()>> + Send + 'static>>;
}Три типажа, по три реализации у каждого. При пуске системы менеджер выбирает подходящую реализацию кластера, основываясь на конфигурации среды выполнения, и обёртывает всё в Arc<dyn Trait>. При этом в оставшейся части базы кода эта информация не фигурирует, и код работает одинаково независимо от того, какой облачный провайдер лежит в основе платформы.
У каждого провайдера есть свои причуды. NSQ использует актор‑подобный паттерн, при котором используется порождаемый tokio цикл продьюсера и каналы передачи сообщений. В GCP используется tokio::sync::OnceCell для ленивой инициализации клиентов, поскольку метод типажа Cluster::topic() должен возвращаться синхронно (вызыватели не должны ожидать (await), чтобы получить ссылку на топик). Но создание GCP‑клиента — это асинхронная операция, в которой вовлечены сетевые вызовы, обеспечивающие аутентификацию. В свою очередь, OnceCell скрывает эту асинхронную реализацию за синхронным интерфейсом, откладывая его до первого использования. AWS SQS/SNS требуются ID публикаторов для того, чтобы упорядочить сообщения в порядке FIFO, тогда как для остальных провайдеров это не важно.
Все эти различия находятся внутри соответствующих модулей. Менеджер видит Arc<dynTopic> и вызывает .publish(). Совершенно не заметно, что под капотом одна реализация обращается к локальному демону NSQпо протоколу TCP, а другая выполняет аутентифицированные HTTPS‑вызовы к AWS. Хранилище объектов работает по такому же паттерну: в нём реализуется часть, совместимая с S3, и часть, совместимая с GoogleCloud.
Все операции в приложении Encore трассируются автоматически: речь о вызовах API, запросах к базе данных, операциях, связанных с публикацией/подпиской, HTTP‑вызовах к внешним сервисам, операциях кэширования. В трассировке учитывается тайминг, вложения (какой запрос к базе данных произошёл внутри какого вызова к API), содержимое запросов и откликов, а также подробности об ошибках. Таким образом, при каждом запросе генерируется много данных. Поэтому был реализован собственный двоичный протокол трассировки — это избавило нас от необходимости конструировать и кодировать сообщения protobuf. Был специально написан сериализатор EventBuffer, записывающий события трассировки в непрерывный байтовый буфер, в котором данные кодируются целыми числами переменной длины:
pub struct EventBuffer {
scratch: [u8; 10],
buf: BytesMut,
}Во временном буфере (scratch buffer) удаётся обойтись без выделения памяти при кодировании данных varint. Трассировочные ID и ID диапазонов записываются по кабелю в виде сырых байт (16 байт и 8 байт соответственно), а не в виде закодированных строк. Точно такой же подход используется в двоичном протоколе трассировки OpenTelemetry. При этом, когда при каждом запросе генерируются десятки событий, связанных с диапазонами, на миллионах событий трассировки ресурсы значительно экономятся.
Также требовалось коррелировать монотонное время (для точного измерения длительности) с временем на часах (для отображения). В TimeAnchor одновременно схватывается одновременно tokio::time::Instant и chrono::DateTime, после чего при каждом последующем событии записывается только монотонный сдвиг. Так удаётся избегать проблем с рассинхронизацией часов, которые бывают настоящим бичом в распределённых системах трассировки, где ни одно «последующее» событие не должно явно происходить до того, как произойдёт «предыдущее».
Выборку трассировки можно сконфигурировать на уровне конечной точки, на уровне сервиса и глобально. Решение о выборке принимается на старте запроса и распространяется на все дочерние участки, поэтому не может случиться, чтобы вы получили трассу лишь частично. Трасса, в которой виден вызов API, но не виден запрос к базе данных — хуже, чем отсутствие всякой трассы, поэтому данное требование было принципиальным.
Синтаксический разбор TypeScript из RustПарсер TypeScript (tsparser) — это отдельный пакет (крейт) Rust, читающий исходный код TypeScript вашего приложения и извлекающий объявления фреймворка Encore: какие существуют сервисы, какие конечные точки они предоставляют, какие базы данных и топики публикации/подписки объявляют, как выглядят запросы и отклики различных типов. Этот инструмент был построен поверх парсера SWC из TypeScript, разбирающего абстрактные синтаксические деревья. Над этим уровнем мы написали наши собственные аналитические проходы. Парсер должен разрешать импорты, прослеживать реэкспорты, а также понимать систему типов TypeScript хотя бы настолько, чтобы иметь возможность извлекать форму запросов и откликов, опираясь на их типы. Речь, в частности, об обобщённых типах, объединениях и сопоставленных типах.
Именно парсер обеспечивает работу инфраструктуры, собранной на основе кода. Когда вы пишете new SQLDatabase("orders", { migrations: "./migrations" }), парсер видит это объявление, извлекает имя базы данных и путь миграции и включает эту информацию в протобуфер с метаданными приложения, который среда выполнения читает при запуске системы. Среда выполнения никогда не делает синтаксический разбор самого TypeScript, а просто получает структурированное описание приложения, а затем конфигурируется в соответствии с этим описанием.
Именно парсер также обеспечивает работу MCP‑сервера, строит архитектурные диаграммы и генерирует документацию к API, поскольку все эти элементы потребляют одни и те же метаданные, производимые парсером.
Что мы сделали бы иначеРаньше стали бы заниматься контекстом ошибок. Обработка ошибок в Rust отлично организована на уровне языка, но поначалу мы слишком сильно полагались на anyhow::Context в общем виде, а не определяли конкретные типы ошибок. Когда что‑нибудь отказывает на трёх уровнях в глубине стека публикации/подписки, сигнал «не удалось опубликовать сообщение» не столь полезен как структурированная ошибка, в которой указаны: имя топика, размер сообщения, провайдер, а также конкретный режим отказа. Лучше бы мы пошагово модифицировали типы ошибок, заменяя их на более качественные.
Скорее выкатили бы адаптер OpenTelemetry. В нашем собственном формате трассировки снимается довольно подробная информация, и не всю её можно с лёгкостью представить в OpenTelemetry (полезная нагрузка запроса/отклика, автоматическое редактирование), и эта информация нам ценна. Но клиенты хотели с самого начала работы экспортировать трассировки из уже установленных у них стеков наблюдаемости, поэтому справедливо упрекали нас в том, что мы не используем открытый стандарт.
Сделали бы ставку на тестирование снимками. Мы используем тестирование снимками, особенно в том, что касается парсинга системы типов TypeScript, и оказалось, что это одна из наиболее эффективных стратегий тестирования базы кода. Всякий раз, когда мы добавляли поддержку новой конструкции TypeScript, тестирование снимками позволяло уловить расхождения на всей поверхности приложения. Этому аспекту сразу следовало бы уделять больше внимания.
Исследовали бы, можно ли использовать среду выполнения Rust при работе с Go. Если бы у нас была одна среда выполнения для двух языков, то поддерживать её было бы значительно легче, чем целых две. Но интерфейс FFI между Go и Rust устроен сложнее, чем в случае с Node.js — дело в том, как работает сборщик мусора в Go. Непросто организовать владение памятью на границе Go и Rust, так как Go может перемещать объекты во время сборки мусора, а с cgo связаны собственные издержки производительности. Эта тема нуждается в исследовании, и оно оказывается непростым.
ЦифрыНа всех развёрнутых у нас облачных инстансах среда выполнения Rust ежедневно обрабатывает миллиарды запросов. Многие из них поступают из компаний, хостинг у которых расположен на собственных серверах, и их трафика мы не видим.
Когда мы перенесли инфраструктурный уровень на Rust, это измеримо повлияло на производительность. На примере бенчмарков, где 150 рабочих потоков конкурентно действовали на протяжении более 10 секунд, мы получили такие результаты (лучший из пяти прогонов, нагрузка сгенерирована oha):
Фреймворк | Запросов в секунду | Запросов в секунду (с валидацией) | Задержка P99 | Задержка P99 (с валидацией) |
Encore.ts | 121 005 | 107 018 | 2,3 мс | 3,6 мс |
Bun + Zod | 101 611 | 33 772 | 3.7ms | 14,9 мс |
Elysia + TypeBox | 82 617 | 35 124 | — | — |
Hono + TypeBox | 71 202 | 33 150 | — | — |
Fastify + Ajv | 62 207 | 48 397 | 4,1 мс | 5,4 мс |
Express + Zod | 15 707 | 11 878 | 11,9 мс | 18,2 мс |
Encore.ts показал девятикратную пропускную способность по сравнению с Express.js, при этом задержка была на 80% ниже. Этот разрыв ещё шире, если включена валидация, поскольку Encore валидирует запросы на уровне Rust, опираясь при этом на уже извлечённую парсером информацию о типах — тогда как другие фреймворки выполняют валидацию на JavaScript. Подробнее эти аспекты рассмотрены в статье Encore.ts — 9x faster than Express.js.
База кода содержит 67 077 строк Rust, которые входят в состав базовой среды выполнения, привязок к JavaScript, парсера TypeScript и инструмента отслеживания процессов. Наряду с ней работает среда выполнения Go, состоящая из 42 629 строк кода, которая по‑прежнему активно поддерживается для работы с приложениями Go. Они вместе используют одну и ту же конфигурацию в формате на основе protobuf, но в остальном совершенно не зависят друг от друга.
Всю обработку среда выполнения осуществляет ниже прикладного уровня: она принимает соединения, маршрутизирует запросы, разбирает и валидирует ввод, управляет пулами базы данных, публикует сообщения, собирает трассы, экспортирует метрики. Код TypeScript в вашем приложении содержит исключительно бизнес‑логику, которая ни импортирует express, ни конфигурирует соединения с базой данных, ни настраивает потребителей системы pub/sub. Приложение объявляет, какая инфраструктура ему требуется, а 67 000 строк на Rust обеспечивают ей работу.
Фреймворк Encore свободно распространяется. Код среды выполнения лежит в главном репозитории под runtimes/core (разделяемая среда выполнения Rust), runtimes/js (привязки Node.js) и tsparser (анализ TypeScript). Можете сами опробовать, как работают все эти элементы.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Как понять, что делает Rust-компилятор: визуализация AST, MIR и LLVM IR | 0 | 10.8 | 15-07-2026 |
| 2 | Свой VPN на Rust: как я спорил с сетью, TLS и самим собой | 0 | 8.78 | 27-06-2026 |
| 3 | Async на Rust завис: как понять, где именно, когда паники нет и стек молчит | 0 | 7.66 | 12-08-2026 |
| 4 | Свой VPN на Rust: как я спорил с сетью, TLS и самим собой | 7 | 8 | 27-06-2026 |
| 5 | [Перевод] Rust 1.97.0: манглинг символов, вывод линкера и поддержка запрета предупреждений в Cargo | 0 | 11.43 | 14-07-2026 |
| 6 | Делаем модуль на C++ для приложения React Native | 0 | 6.53 | 30-07-2026 |
| 7 | Point0 — фулстек TypeScript-фреймворк на Bun и React, о котором я мечтал | 5 | 7 | 01-07-2026 |
| 8 | [Перевод] Пишем движок для JavaScript с нуля | 0 | 6.36 | 09-06-2026 |
| 9 | AngaraBase: новая HTAP СУБД | 7 | 8 | 29-06-2026 |
| 10 | Я написал ASGI-фреймворк, в котором дерево папок — это и есть API | 0 | 13.37 | 24-07-2026 |