javascript

Ускоряем drop in replace Next.js в 100 раз. Часть 2

  • среда, 26 августа 2026 г. в 00:00:10
https://habr.com/ru/companies/t2/articles/1069300/

В первой части мы разобрали архитектуру Rari, выяснили, почему Rust перед V8 даёт выигрыш в производительности, и посмотрели на бенчмарки. В этой статье хочется поговорить о том, что обычно остаётся за кадром бенчмарков: как это работает в продакшене, что конкретно поменялось под капотом, где ломается совместимость с Next.js, как мы портировали их SWC-плагины и как производительность выросла с 4x до 67x.

Один процесс вместо целого стека технологий

Next.js в продакшене почти никогда не работает без связки с nginx. Обычно nginx используется для:

  • TLS termination

  • Static file serving

  • Connection buffering и slow client handling

  • Load balancing между несколькими Node.js процессами

  • Graceful restart без downtime

Без nginx Node.js обрабатывает все запросы через event loop. Медленные клиенты блокируют соединения, TLS handshake съедает CPU, отдача статики конкурирует с SSR за тот же event loop. Для multi-core ещё нужны PM2, k8s replicas или systemd. Один Node.js процесс работает в одном event loop и на одном ядре. Cluster mode помогает, но добавляет проблемы с shared state и memory. В Rari данные вопросы решены на уровне архитектуры. Axum + Tokio обрабатывают десятки тысяч конкурентных соединений, TLS, static files, streaming. Тут важно отметить, что Next.js + nginx + PM2 — это три независимых компонента, которые нужно мониторить, обновлять и контролировать. Большое преимущество Rari в том, что это один процесс, один бинарник на одном порте.

JS runtime pool: multi-core без внешних инструментов

Недавно в Rari была влита долгожданная фича — JS runtime pool (PR #307, #318). Теперь один процесс Rari может запускать несколько V8 isolates и распределять между ними запросы:

Один процесс rari, один HTTP порт
    → Axum принимает запросы (multi-threaded, tokio)
        → JsRuntimePool
            → V8 isolate #1
            → V8 isolate #2
            → V8 isolate #N
        → round-robin для обычных запросов
        → pinning для streaming (поток не мигрирует между isolates)

Round-robin распределяет обычные запросы. Однако Streaming-запросы всегда уходят к одному isolate на всё время жизни. Это связано с тем, что нельзя мигрировать активный поток между runtime'ами. HMR invalidation рассылается всем isolates. Если один isolate умирает (OOM, crash, зависший рендер), роутер его обходит, остальные продолжают обслуживать трафик. Пользователь может получить чуть более медленный ответ, но не 502. Это решает проблему, которую решает pm2 multi-core из коробки.

Заявленная совместимость не означает идентичность

Rari позиционируется как drop-in replacement для Next.js. На уровне API: App Router, Server Components, 'use client', streaming. Но если капнуть глубже, от Next.js в коде Rari фактически ничего не осталось. Вот что конкретно поменялось:

Next.js

Rari

Runtime

Node.js (V8 + libuv)

Rust (tokio) + V8 через deno_core

Event loop

Single-threaded, libuv

Multi-threaded, tokio work-stealing

Concurrency

Один поток на всё

HTTP на пуле OS threads, V8 на отдельном thread

Memory model

V8 heap, GC, shared globals

Rust ownership + V8 isolate, изолированные heap'ы

Startup

resolve → load → parse → compile → execute

V8 snapshot deserialize

HTTP layer

Next.js

Rari

HTTP server

Node.js http module

axum (Rust)

Middleware

Express-like chain

tower-middleware (Rust)

Streaming

Node.js ReadableStream

Rust async streams → chunked transfer encoding

TLS

Внешний (nginx) или Node.js TLS

Встроенный (axum)

Static files

Next.js server

axum + response cache

В Next.js каждый запрос проходит через Node.js HTTP stack: http.createServer → middleware chain → route handler → response. В Rari каждый запрос проходит через axum: Rust route dispatch → tower middleware → handler, что сильно даёт прирост к производительности, так как нет Node.js stream API, Buffer и process.nextTick.

Module system

Next.js

Rari

Resolution

Node.js require() / ESM

Vite build + custom module loader

Runtime modules

node_modules

Pre-built bundles

CJS/ESM

Dual mode

ESM only

Dynamic import

Node.js

Vite-transformed

В Next.js модули резолвятся в runtime через Node.js module system. require(), node_modules, поддерживается CJS/ESM dual mode. В Rari изначально была заявлена только ESM модель, модули собираются на этапе build через Vite. В runtime нет require() и node_modules resolution — только pre-built bundles.

Fetch

Next.js

Rari

HTTP client

undici (JS)

hyper (Rust) поверх tokio

Overhead

patchFetch → undici → dispatcher → event loop → promises

op → Rust HTTP client → result → op

Connection pool

undici Pool

tokio connection pool

В Next.js fetch() проходит через JavaScript-обёртки: patchFetch, undici, dispatcher, event loop, promises, microtasks. В Rari fetch() — это один op из V8 в Rust, один HTTP-вызов через hyper, один op обратно. Об этом подробно уже рассказывали в первой части.

Client-side protocol

Именно здесь ломаются Parallel Routes и Intercepting Routes.

Next.js

Rari

Client router

Next.js App Router

Rari client router

Navigation headers

Next-Router-State-TreeNext-Router-Prefetch

Нет

Slot tracking

Server знает про client slots

Rust-роутер не знает про slots

Soft navigation

Server различает soft/hard navigation

Server не различает

Клиентский роутер Next.js добавляет к запросам заголовки, по которым сервер решает, что отдавать: intercepted layout или полную страницу, один слот или весь layout. В Rari этого нет — axum про такие заголовки не знает. Добавить их можно, но это значит заново строить внутренний протокол Next.js в Rust-роутере, который к тому же меняется с каждым релизом. Поэтому фича отложена и в ближайших планах не значится. См. https://github.com/rari-build/rari/discussions/132

deno ops: почему системные вызовы в Rari быстрее

В Node.js каждый системный вызов проходит через JavaScript-обёртки. fs.readFile() → JS binding → libuv → C callback → JS callback → Promise. Получается огромная куча аллокаций, переключений контекста, сериализаций. В Rari системные вызовы идут через deno ops — типизированные sync- и async- операции между V8 и Rust, встроенные в deno_core. Тут нет FFI с сериализацией через JSON. Это прямой blazing вызов Rust-функции из V8 с минимальным overhead.

Почему это быстро:

  • Нет JavaScript-обёрток. В Node.js fs.readFile() — это JS-функция, которая вызывает C binding, который вызывает libuv, который вызывает OS. В Rari Deno.readFile() — это один op, который напрямую вызывает Rust tokio::fs::read без промежуточных слоёв.

  • Zero-copy для буферов. Когда V8 передаёт Uint8Array в op, deno_core может передать указатель на underlying buffer без копирования. В Node.js каждый Buffer — это аллокация и копирование.

  • Async ops на tokio. await Deno.readFile() в V8 мапится на tokio::fs::read в Rust. Нет libuv, thread pool переключений и callback hell. Tokio work-stealing scheduler обрабатывает async I/O напрямую.

  • Типизация на уровне ABI. Ops не используют JSON сериализацию. Аргументы и возвращаемые значения сериализуются через бинарный формат, специфичный для каждого op. Меньше overhead, меньше аллокаций.

Конкретный пример — fetch:

Next.js

Rari

Вызов

fetch() → JS patchFetch → undici → libuv → OS

fetch() → op → Rust hyper → OS

Слоёв абстракции

5+

1

Сериализаций

Несколько (JS objects, headers, body)

Одна (op args → Rust)

Event loop переключений

Несколько (microtasks, poll phase)

Ноль (tokio async)

File system и environment

Ещё пример — fs:

Next.js

Rari

File system

Node.js fs module (JS → C → libuv → OS)

deno ops (V8 → Rust → OS)

Env variables

process.env (JS object)

Rust env → op → V8

Permissions

Нет (всё доступно по умолчанию)

deno_core permission model

Overhead на вызов

JS binding + libuv + callback

Один op, одна сериализация

Что это значит на практике

Давайте сравним Next.js с rari по drop in replace фичам:

  • Стандартный App Router — есть

  • Server Components, Client Components, streaming — есть

  • API routes, server-side fetch — есть

  • 'use cache' — есть (см. секцию про SWC-плагин ниже)

  • Parallel Routes, Intercepting Routes — нет (нет клиентского протокола)

  • Next.js middleware с хаками на заголовки — нет (нет Next-Router-* заголовков)

  • require() в runtime — нет (нет Node.js module system)

  • Динамический import() из node_modules — нет (модули собираются на build)

По моему опыту, Rari закрывает 80–90% кейсов стандартного Next.js-приложения. Если ваш проект завязан на внутренние механизмы Next.js — middleware хаки, кастомные заголовки, dynamic require, Parallel Routes, — нужно быть готовым к тому, что миграция потребует переписывания этих частей.

Parallel Routes

Parallel Routes (@folder) рендерят несколько независимых слотов одновременно внутри одного layout. Каждый слот обновляется независимо. В Next.js серверный и клиентский роутеры глубоко интегрированы: сервер знает про все слоты, клиентский роутер обновляет их независимо, RSC payload содержит информацию о привязке слотов. Это протокол между сервером и клиентом. В Rari Rust-слой отвечает за routing, V8 рендерит RSC. Между ними — message channel. Rust-роутер оперирует запросами и ответами. Он не знает ни про клиентские slot'ы, ни про навигацию внутри layout.

Intercepting Routes

Intercepting Routes ((.), (..), (...)) перехватывают клиентскую навигацию и показывают контент в модальном окне, сохраняя URL. Сервер должен различать «пользователь кликнул на ссылку» и «вставил URL в адресную строку». В Next.js это реализовано через заголовки Next-Router-State-Tree и Next-Router-Prefetch. По ним сервер решает, что отдать: intercepted layout или полную страницу. В Rari используется отдельный HTTP-слой на Rust. Он не знает ни про эти заголовки, ни про клиентский роутер, ни про разницу между soft и hard navigation. Добавить это можно, но тогда придётся воспроизвести значительную часть внутреннего протокола Next.js внутри Rust-роутера, чего бы точно не хотелось, так как с каждым обновлением Next.js этот протокол может меняться.

'use cache' directive: как портировали Next.js SWC плагин и с чем столкнулись

Кроме response cache, который работает на уровне HTTP и кэширует весь response целиком, Next.js 15+ добавил более гранулярный механизм — директиву 'use cache', которую можно ставить на отдельные функции. Это фрагментарное кэширование с closure tracking, SHA1 reference IDs, hoisting'ом и регистрацией server references. Rari достаточно давно поддерживал 'use client' и 'use server', но не 'use cache'. Интеграция новой фичи оказалась весьма сложной.

Что такое 'use cache' и почему это сложно

Пишете функцию с директивой:

async function getUserProfile(userId: string) {
  'use cache'
  const user = await fetchUser(userId)
  const posts = await fetchPosts(userId)
  return { user, posts }
}

Фреймворк должен превратить её в три сущности:

  1. Hoisted inner function — оригинальная логика, поднятая выше по дереву.

  2. Cache wrapper — обёртка через React cache() API с регистрацией server reference.

  3. Bound replacement — оригинальная декларация заменяется на .bind() с захваченными переменными.

Проблема в том, что регулярки тут не работают. Нужен полноценный AST transform с учётом scope, shadowing, destructuring, generics, hoisting order. Написать такое с нуля — это недели работы и десятки edge cases, которые вы неизбежно пропустите.

Решение: взять код из Next.js и портировать

В Next.js этот трансформер написан на Rust и использует SWC-крейты под капотом. Мы используем те же SWC-крейты. Вместо того чтобы переписывать алгоритм на JavaScript (Babel plugin) или использовать @swc/core через Wasm bridge, был портирован Next.js-код напрямую в виде napi-rs native addon.

Почему не Babel plugin? Babel работает на JavaScript. Каждый AST-узел — это JS-объект. Для файла на 500 строк это тысячи аллокаций, сериализаций, GC pressure. Плюс нужно заново изобрести все edge cases, которые Next.js уже решил на Rust.

Почему не @swc/core через Wasm bridge? Wasm ↔ JS serialization на каждую AST-операцию, ограниченный VisitMut trait, неудобный threading. Мы уже используем Rust-крейты SWC напрямую в Rari — зачем добавлять overhead?

Почему napi-rs? Скорость разработки (алгоритм уже написан), производительность (zero-copy AST access, что критично для HMR), прямой доступ ко всем SWC-крейтам. Плюс отдельный Rust crate со своим SWC dependency tree и platform-specific binaries для npm-дистрибуции — это удобно и упрощает декомпозицию проекта.

Как устроен трансформ

PR #195 добавил crate crates/use-cache-transform с 6 модулями.

directive.rs — двухуровневая детекция. Сначала выполняется быстрая regex-проверка: есть ли в исходнике вообще строка "use cache" (именно quoted string literal, а не просто substring, иначе возможны ложные срабатывания). Если да — запускается AST-level detection через SWC visitor, который проверяет только leading prologue function body. Поддерживаются варианты с кавычками ", ', ` и с kind-суффиксом типа "use cache: private".

id.rs — генерация детерминистичных reference IDs через SHA1 от salt + filename + export name. 42 hex символа: 40 SHA1 + 2 type bytes. Я специально сохранил совместимость с Next.js — reference IDs должны быть одинаковыми при одинаковых input'ах, иначе клиент не сможет зарезолвить server action по ID. На этом моменте я набил достаточно много шишек: малейшее расхождение в формате ломает весь протокол.

closure.rs — самый сложный модуль. Он отслеживает, какие переменные функция захватывает из внешнего scope. Сначала собирает module-level identifiers (импорты, top-level declarations), затем проходит по AST функции и определяет, какие из этих идентификаторов используются внутри. Но не так всё просто — нужен scope-aware анализ с учётом shadowing.

transform.rs — оркестрация. Парсит source как TypeScript с TSX, применяет VisitMut visitor, генерирует обратно код через SWC Emitter.

hoist.rs — AST construction. Строит hoisted inner function с closure array parameter, cache wrapper, registerServerReference call и .bind() replacement. Сохраняет generics оригинальной функции.

С какими проблемами столкнулись

Портирование чужого Rust-кода выглядит просто на словах. На практике SWC-экосистема не статична, и каждая мелочь ломает всё.

SWC v9 broke everything

Rari использует SWC v9. Next.js вариант написан под более ранние версии. Пришлось переделывать практически каждое API:

SWC < v9

SWC v9

EsConfig

EsSyntax

Parser::new(syntax, source, comments)

Parser::new(syntax, StringInput::from(source), comments)

Emitter::new(cm, comments, wr)

Emitter { cfg, cm, comments, wr } (struct construction)

Wtf8Atom::to_string()

Wtf8Atom::to_string_lossy()

BlockStmt { stmts, span }

BlockStmt { stmts, span, ctxt } — добавилось обязательное поле syntax context

Ident::new(sym, span)

Ident::new(sym, span, ctxt)

Syntax::Typescript(TsConfig { .. })

Syntax::Typescript(TsSyntax { .. }) плюс нужно явно включить features = ["typescript"]

ForInStmt { left: VarDeclOrExpr, .. }

ForInStmt { left: ForHead, .. } — enum вместо union type

Изначально parser был захардкожен на Syntax::Es — TypeScript и TSX не парсились вообще. Пришлось переделать его на Syntax::Typescript. Это была одна из самых коварных проблем. Возьмём код:

import type { User } from './types'

function cached() {
  'use cache'
  const user: User = await fetchUser()
  return user
}

User — это type-only import, он не существует в runtime. Если добавить его в closure variables, сгенерируется битый код, который попытается передать несуществующую переменную в .bind(). Изначальный алгоритм этого не учитывал и он собирал все импорты подряд. Пришлось добавить проверку import_decl.type_only и ImportSpecifier::Named.is_type_only.

Shadowing и destructuring

Ещё один edge case — shadowing с destructuring:

import { x } from 'module'

function cached() {
  'use cache'
  const { x: renamed } = someObject
  return renamed + x
}

Здесь x из import не должен попадать в замыкание, потому что внутри функции x он не используется. Но если написать const { x } = someObject, то x — это уже новая локальная переменная, которая shadow импортированную x, поэтому импортированная x тоже не должна попасть в closure. А вот в const { x: renamed } x —  это ключ объекта (не новая переменная), а renamed — это новая локальная. Нужно правильно различать ObjectPatProp::KeyValue (добавляем value), ObjectPatProp::Assign (добавляем key) и ObjectPatProp::Rest (добавляем rest identifier). Плюс ObjectPatProp::KeyValue с дефолтом — отдельный подкейс.

Hoisting ordering

JavaScript var hoisting сам по себе недостаточен. Вот два варианта:

// НЕПРАВИЛЬНО — ReferenceError при evaluation:
const cached = __cache.bind(null, ...closureVars)
function __cache(closure, args) { ... }

// ПРАВИЛЬНО:
function __cache(closure, args) { ... }
const cached = __cache.bind(null, ...closureVars)

Изначально в visit_mut_module_items extra_items (hoisted declarations) добавлялись после new_items. Из-за этого при evaluation __cache оказывался undefined в момент вызова .bind(). Пришлось поменять порядок сборки AST-узлов, чтобы hoisted функции объявлялись до их использования.

Directive stripping

Изначально strip_directives_from_body удалял все string-literal expression statements в function body. Как показало тестирование это решение удаляло и пользовательские строки:

function cached() {
  'use cache'
  'some description string'  // удалялась зря!
  return compute()
}

Решение: удалять только leading prologue — непрерывную последовательность директив с начала блока. Как только встретился non-string-literal statement, stripping останавливается.

Generics preservation

Если оригинальная функция была generic:

async function cached<T>(arg: T): Promise<T> {
  'use cache'
  return arg
}

Надо сохранить type_params в hoisted inner function. Изначально они терялись, из-за чего TypeScript в сгенерированном коде ругался на отсутствие generics.

ExportDefaultDecl

Отдельный кейс: export default function cached() { 'use cache' }. Это не просто export function, не просто function, не export const. Нужен отдельный match arm в maybe_transform_item. Bound replacement генерируется как export default __cache.bind(null, ...closureVars).

Native addon discovery

Изначально искал .node бинарник по process.cwd(). Это работало в workspace, но ломалось у published package consumers — они устанавливали пакет в свой node_modules, и loader не мог найти бинарник. Пришлось перейти на package-relative resolution через import.meta.url. Плюс пришлось делать platform-specific пакеты (@rari/use-cache-transform-linux-x64, @rari/use-cache-transform-darwin-arm64 и т.д.). Для native addons выбрали npm-дистрибуцию.

Dependency weight

lru-cache, который изначально использовался для runtime кэша, вызвал дебаты из-за своей тяжеловесности. После обсуждения с автором проекта заменили его на quick-lru — ~300 строк, zero dependencies.

Результат

На выходе — нативный Rust crate, который портирует алгоритм Next.js и работает внутри Vite plugin под флагом experimental.useCache:

// rari.config.ts
export default defineRariOptions({
  experimental: { useCache: true },
})

Первоначально простая, как казалось, задача в итоге сгенерировала много мелких деталей, каждая из которых ломает всё, если её пропустить. Совместимость с Next.js сильно помогла нам: не пришлось решать сотни edge cases, которые кто-то уже решил до нас и которые мы неизбежно встретили бы снова, если бы писали всё с нуля.

Storage backends: не только in-memory

Описанный выше трансформ генерирует код, который кэширует результаты в памяти процесса. Это быстро, но есть проблема: кэш теряется при рестарте и не шарится между инстансами. Для production-среды с несколькими pod'ами это означает холодный старт каждого инстанса и дублирование работы. Rari решает это через 'use cache: remote' — отдельную директиву для persistent-кэширования:

async function getExpensiveData() {
  'use cache: remote'
  // Этот результат шарится между всеми инстансами
  return await fetchExpensiveComputation()
}

Конфигурация через rari.config.ts:

export default {
  experimental: {
    useCache: true,
    useCacheRemote: {
      handler: 'redis', // или 'redb'
      url: 'redis://localhost:6379/rari',
    },
  },
}

Три варианта storage:

  • Memory (default) — быстрый in-memory LRU кэш в каждом процессе. Используется для 'use cache' без remote.

  • Redis — distributed cache для multi-instance deployments. Идеален для SSR/RSC, где несколько pod'ов обслуживают один и тот же контент.

  • Redb — embedded persistent database (как SQLite, но Rust-native). Один файл на диске, переживает рестарты и не требует внешнего сервиса. Хорош для single-instance deployments, где нужна persistence.

Под капотом это работает через Deno ops:

// Rust extension регистрирует ops для remote cache
op_cache_remote_get(key) -> Option<Vec<u8>>
op_cache_remote_set(key, value, ttl_ms)

TypeScript runtime выбирает backend через registry:

const storage = getStorage('remote')  // -> RedisCacheStorage или RedbCacheStorage
const cached = await storage.read(key)
if (!cached) {
  const result = await computeExpensiveValue()
  await storage.write(key, result, ttl)
  return result
}
return cached

Это даёт гибкость: для local dev используешь memory, для staging — Redb (один файл, легко чистить), для production — Redis (distributed, monitored, backed up).

6 слоёв кэширования в Rari и как их настраивать

Тут стоит отметить, что кэширование в Rari достаточно большой и сложный механизм. Он разбит на 6 независимых слоёв, каждый из которых отвечает за свой тип данных:

Слой

Что кэширует

Лимит по умолчанию

ResponseCache

RSC payloads, HTML

1000 entries

ImageCache

Optimized images

100 MB soft limit

OgImageCache

OG images

20 entries

LayoutHtmlCache

Layout HTML

Без лимита (DashMap)

ThreadSafeCache

JS modules

5000 entries

Fetch cache

Fetch deduplication

1000 entries

С хранением кеша в памяти, есть проблема: каждый слой живёт в памяти процесса. Если у вас несколько pod'ов за load balancer'ом, кэш не шарится между ними.

Pluggable backends: один trait для всех слоёв

Rari вводит CacheHandler trait (аналог Next.js cacheHandlers):

#[async_trait]
pub trait CacheHandler: Send + Sync + Debug {
    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, CacheError>;
    async fn set(&self, key: &str, value: Vec<u8>, ttl: u64) -> Result<(), CacheError>;
    async fn set_with_tags(&self, key: &str, value: Vec<u8>, ttl: u64, tags: &[String]) -> Result<(), CacheError>;
    async fn invalidate(&self, key: &str) -> Result<(), CacheError>;
    async fn invalidate_by_tag(&self, tag: &str) -> Result<(), CacheError>;
    async fn clear(&self) -> Result<(), CacheError>;
}

Встроенные handlers

  • MemoryCacheHandler (default) — текущая in-memory LRU реализация. DashMap + LruCache + tag_index. Backward compatible.

  • RedisCacheHandler — distributed cache для multi-instance deployments. Tag-based invalidation через Redis sets.

  • RedbCacheHandler — disk-backed, переживает рестарты. Хорош для preview deployments.

  • NoopCacheHandler — выключает кэш полностью. Критично для CDN deployments (об этом ниже).

Конфигурация: каждый слой настраивается отдельно

Через rari.config.ts:

export default {
  cache: {
    handlers: {
      redis: { kind: "redis", url: "redis://localhost:6379" },
      memory: { kind: "memory" },
      noop: { kind: "noop" },
    },
    response: { handler: "redis" },
    image:    { handler: "memory" },
    ogImage:  { handler: "noop" },
    layout:   { handler: "memory" },
    module:   { handler: "memory" },
    fetch:    { handler: "memory" },
  }
}

Зачем NoopCacheHandler: сценарий с CDN

Когда Rari стоит за CDN, in-app кэш часто вреден. CDN уже кэширует HTML, RSC, images — до Rari доходят только cache miss'ы и authenticated requests. In-app кэш хранит entries, которые никто не запрашивает, и просто жжёт память. То же самое с images: если CDN делает optimization (Cloudflare, Vercel), ImageCache/OgImageCache просто занимают RAM, не помогая обрабатывать реальные запросы. Решение — выключить их через noop:

cache: {
  response: { handler: "noop" },  // CDN кэширует HTML/RSC
  image:    { handler: "noop" },  // CDN делает image optimization
  ogImage:  { handler: "noop" },  // CDN кэширует OG images
  layout:   { handler: "memory" },
  module:   { handler: "memory" },
  fetch:    { handler: "memory" },
}

Как мы видим, конфиг Rari для работы с кешем даёт гибкость под конкретную инфраструктуру без переписывания кода cache consumers.

Если V8 можно не трогать на cache hit, можно ли не будить его чаще?

Response cache показывает идею: если исключить V8, можно получить прирост производительности в десятки раз. Но, увы, cache работает не всегда. Для Dynamic handlers, personalized responses, BFF-агрегации, API-like сценариев Rari использует V8 snapshot, который просыпается на каждый запрос. Я задался закономерным вопросом: можно ли убрать V8 из hot path не только на cache hit, но и для части dynamic server-side handlers? Скомпилировать TypeScript в native code, минуя V8. Per-request arena вместо GC. Статически выделенная память. Изоляция на уровне процесса. Архитектурно это похоже на CGI: один запрос — один изолированный execution context. Только вместо Perl-скрипта уже TypeScript, скомпилированный в native binary. Но это тема для отдельной статьи.

Финальный вывод

В итоге что мы имеем: HTTP, кэш, стриминг, память, observability и изоляция — уезжают в Rust. Rari уже сейчас показывает, что это работает: Rust + V8, snapshot вместо parse-on-start, один процесс вместо стека из nginx + Node.js + PM2, response cache с поддержкой Redis и Redb для distributed deployments, который выключает V8 на hot path. Для BFF, API routes, SSR и стриминга вполне себе рабочая альтернатива. В следующей части мы поговорим про AOT-компиляцию TypeScript и возвращение к идее CGI в её новом переосмыслении.