[perf][infra] Холодная загрузка по мобильной сети: статика и API отдаются без сжатия (~3.7 МБ по проводу), у хэшированных ассетов нет cache-заголовков #346

Open
opened 2026-07-04 19:56:10 +03:00 by agent_vscode · 0 comments
Collaborator

Суть

Холодная загрузка сервиса на мобильном интернете занимает многие секунды, и главная причина — не размер бандла (это #342), а способ доставки: сервер отдаёт всю статику и все API-ответы без сжатия и без кэш-заголовков. Замер по реальной прод-сборке:

Что По проводу сейчас С brotli
eager-JS (index 1 920 КБ + lib 759 КБ + mantine 514 КБ + katex 257 КБ) 3 300 КБ ~950 КБ
eager-CSS (vendor-mantine 220 КБ + index 156 КБ) ~380 КБ ~60 КБ
API JSON (контент страницы, дерево — сотни КБ на больших страницах) несжатый в разы меньше

На типичном мобильном канале 5–8 Мбит/с это ~4–6 секунд чистой передачи против ~1.5 c со сжатием — ещё до начала парсинга JS. Повторные заходы почти не выигрывают: ассеты content-hashed, но без явного Cache-Control браузер живёт на эвристике, а мобильные браузеры агрессивно вытесняют кэш и перепроверяют ресурсы (даже 304 — это десятки запросов × 150–300 мс мобильного RTT).

Диагноз

  1. Сжатие не включено нигде. В main.ts#L60-L62 регистрируются только fastify-ip/multipart/cookie@fastify/compress отсутствует в проекте. @fastify/static в static.module.ts#L68-L71 зарегистрирован без preCompressed, и в dist/ нет .br/.gz-файлов. Дефолтный деплой (docker-compose.yml) отдаёт Node напрямую на :3000 без реверс-прокси — сжимать некому вообще.
  2. Нет cache-заголовков для ассетов. Cache-Control выставлен только для index.html (no-cache — правильно, static.module.ts#L76). Все /assets/* content-hashed (index-yje0b65P.js) и могли бы кэшироваться навечно (immutable), но явных заголовков нет.
  3. HTTP/1.1 без мультиплексирования. В index.html 9 modulepreload-чанков + CSS + шрифты + локаль — на HTTP/1.1 упирается в лимит 6 соединений; на мобильном RTT очередь заметна. Node-приложение само по себе HTTP/2 не отдаёт.

Границы изменения

apps/client/vite.config.ts (плагин пре-сжатия), apps/server/src/integrations/static/static.module.ts (preCompressed + кэш-заголовки), apps/server/src/main.ts (compress для API), опционально docker-compose.yml/README (рекомендация прокси). Поведение фич 1:1, меняется только доставка байтов. БД/API-контракты/MCP не затрагиваются.

Решение

1. Пре-сжатие статики на этапе сборки (brotli + gzip)

Лучший вариант для self-hosted: сервер не тратит CPU на сжатие на лету, файлы сжимаются один раз при сборке.

// vite.config.ts — emit .br and .gz next to each built asset
import { compression } from "vite-plugin-compression2";

plugins: [
  react(),
  compression({
    algorithms: ["brotliCompress", "gzip"],
    // index.html is rewritten at server boot (window.CONFIG injection),
    // a precompressed copy would go stale — never precompress it
    exclude: [/index\.html$/],
  }),
],
// static.module.ts — serve .br/.gz when the browser accepts them
await app.register(fastifyStatic, {
  root: clientDistPath,
  wildcard: false,
  preCompressed: true,
});

2. Immutable-кэш строго для хэшированных ассетов

Важно: immutable нельзя вешать на всю статику — в dist/ кроме /assets/* лежат нехэшированные файлы (/locales/*.json, /vad/*, иконки, manifest), которые меняются между деплоями. Скоупить через отдельную регистрацию по префиксу либо setHeaders:

await app.register(fastifyStatic, {
  root: clientDistPath,
  wildcard: false,
  preCompressed: true,
  setHeaders: (res, path) => {
    // content-hashed files only: cache forever
    if (path.includes("/assets/")) {
      res.setHeader("cache-control", "public, max-age=31536000, immutable");
    }
    // index.html must be revalidated on every load (config injection, deploys)
    if (path.endsWith("index.html")) {
      res.setHeader("cache-control", "no-cache, no-store, must-revalidate");
    }
    // everything else (locales, vad, icons) keeps default revalidation (etag/last-modified)
  },
});

Результат: повторный заход = скачивание только index.html (пара КБ) + API.

3. @fastify/compress для динамических ответов

app.register(compress) в main.ts — сжимает API-JSON (контент страницы, дерево, комментарии) и переписанный share-SEO HTML (share-seo.controller.ts). Статику не трогает — она уже отдаётся pre-compressed. Проверить взаимодействие с SSE/stream-ответами AI-чата (исключить их из сжатия, см. крайние случаи).

4. Документировать реверс-прокси с HTTP/2/3 (опционально)

Для инстансов за caddy/nginx/traefik — включённый HTTP/2 убирает лимит 6 соединений. Добавить в README/compose-пример опциональный caddy-фронт (авто-TLS + h2). Если прокси уже есть и на нём включён brotli — пп. 1/3 частично закрываются конфигом прокси, но preCompressed всё равно выгоднее (нулевой CPU на каждом запросе).

Крайние случаи

  • index.html переписывается при старте сервера (static.module.ts#L55-L64: инъекция window.CONFIG через index-template) — пре-сжатую копию index.html создавать нельзя (будет отдаваться устаревший шаблон без конфига). Исключить из плагина сжатия (см. сниппет) и убедиться, что прямой запрос /index.html через fastifyStatic не находит .br-соседа.
  • Share-SEO/alias контроллеры читают и переписывают index.html на лету — их ответы сжимает только @fastify/compress (п. 3), не preCompressed; проверить, что мета-инъекция не ломается.
  • SSE/streaming AI-чата: сжатие буферизует поток — исключить text/event-stream (у @fastify/compress есть настройка) либо убедиться, что дефолт не трогает эти маршруты.
  • Локали и vad-ассеты не хэшированы — им нельзя immutable (см. п. 2), иначе после деплоя пользователи застрянут на старых переводах/модели.
  • Attachments (attachment.controller.ts) идут отдельным путём со своими заголовками — не трогаем; сжимать бинарники (изображения) бессмысленно, исключить по mime, если compress регистрируется глобально.
  • Рост образа Docker: .br+.gz копии увеличат слой с client/dist (~+30–40%, дешёво относительно выигрыша).
  • Слабый CPU сервера: именно поэтому предпочесть preCompressed, а @fastify/compress ограничить JSON/HTML (у бинарников и так low ratio).

Тесты / проверка

  • curl -H "Accept-Encoding: br" -sI https://<host>/assets/index-*.jscontent-encoding: br + cache-control: …immutable.
  • curl -sI …/index.html → нет content-encoding от preCompressed (или корректный динамический), no-cache, window.CONFIG присутствует в теле.
  • curl -H "Accept-Encoding: br" -sI …/api/pages/... → сжатый JSON.
  • Локали: после «деплоя» (замены файла) клиент получает новый перевод без hard-refresh.
  • AI-чат стриминг работает (SSE не буферизуется).
  • Lighthouse/DevTools на профиле Slow 4G: время до интерактивности до/после; повторная загрузка — только index.html + API в Network-панели.

Вне скоупа

  • Уменьшение самого eager-бандла (route splitting, lazy katex/lowlight/drawio) — #342 (слой A).
  • Латентность печати — #343; фоновые ре-рендеры — #344; панель комментариев — #340.
  • Service worker / offline-кэш повторных загрузок — приземляется в PR #120 (unified service worker); настоящая ишью лечит холодную первую загрузку, SW добьёт повторные.
  • CDN для статики — отдельное инфра-решение, не требуется для основного выигрыша.

План работ

  1. vite-plugin-compression2 (br+gz, exclude index.html) + preCompressed: true в static.module.ts.
  2. Кэш-заголовки: immutable для /assets/*, no-cache для index.html, дефолт для остального (п. 2).
  3. @fastify/compress для API/HTML с исключением SSE и бинарных mime (п. 3).
  4. Проверка share-SEO путей и стриминга AI-чата.
  5. README/compose: рекомендация HTTP/2-прокси (опционально).
  6. Замеры до/после: curl -I матрица + Lighthouse Slow 4G + повторная загрузка.
# Суть Холодная загрузка сервиса на мобильном интернете занимает многие секунды, и главная причина — не размер бандла (это #342), а **способ доставки**: сервер отдаёт всю статику и все API-ответы без сжатия и без кэш-заголовков. Замер по реальной прод-сборке: | Что | По проводу сейчас | С brotli | |---|---|---| | eager-JS (index 1 920 КБ + lib 759 КБ + mantine 514 КБ + katex 257 КБ) | **3 300 КБ** | ~950 КБ | | eager-CSS (vendor-mantine 220 КБ + index 156 КБ) | **~380 КБ** | ~60 КБ | | API JSON (контент страницы, дерево — сотни КБ на больших страницах) | несжатый | в разы меньше | На типичном мобильном канале 5–8 Мбит/с это **~4–6 секунд чистой передачи против ~1.5 c со сжатием** — ещё до начала парсинга JS. Повторные заходы почти не выигрывают: ассеты content-hashed, но без явного `Cache-Control` браузер живёт на эвристике, а мобильные браузеры агрессивно вытесняют кэш и перепроверяют ресурсы (даже 304 — это десятки запросов × 150–300 мс мобильного RTT). # Диагноз 1. **Сжатие не включено нигде.** В [main.ts#L60-L62](apps/server/src/main.ts#L60-L62) регистрируются только `fastify-ip`/`multipart`/`cookie` — `@fastify/compress` отсутствует в проекте. `@fastify/static` в [static.module.ts#L68-L71](apps/server/src/integrations/static/static.module.ts#L68-L71) зарегистрирован без `preCompressed`, и в `dist/` нет `.br`/`.gz`-файлов. Дефолтный деплой ([docker-compose.yml](docker-compose.yml)) отдаёт Node напрямую на `:3000` без реверс-прокси — сжимать некому вообще. 2. **Нет cache-заголовков для ассетов.** `Cache-Control` выставлен только для `index.html` (`no-cache` — правильно, [static.module.ts#L76](apps/server/src/integrations/static/static.module.ts#L76)). Все `/assets/*` content-hashed (`index-yje0b65P.js`) и могли бы кэшироваться навечно (`immutable`), но явных заголовков нет. 3. **HTTP/1.1 без мультиплексирования.** В `index.html` 9 modulepreload-чанков + CSS + шрифты + локаль — на HTTP/1.1 упирается в лимит 6 соединений; на мобильном RTT очередь заметна. Node-приложение само по себе HTTP/2 не отдаёт. # Границы изменения `apps/client/vite.config.ts` (плагин пре-сжатия), `apps/server/src/integrations/static/static.module.ts` (preCompressed + кэш-заголовки), `apps/server/src/main.ts` (compress для API), опционально `docker-compose.yml`/README (рекомендация прокси). Поведение фич 1:1, меняется только доставка байтов. БД/API-контракты/MCP не затрагиваются. # Решение ## 1. Пре-сжатие статики на этапе сборки (brotli + gzip) Лучший вариант для self-hosted: сервер не тратит CPU на сжатие на лету, файлы сжимаются один раз при сборке. ```ts // vite.config.ts — emit .br and .gz next to each built asset import { compression } from "vite-plugin-compression2"; plugins: [ react(), compression({ algorithms: ["brotliCompress", "gzip"], // index.html is rewritten at server boot (window.CONFIG injection), // a precompressed copy would go stale — never precompress it exclude: [/index\.html$/], }), ], ``` ```ts // static.module.ts — serve .br/.gz when the browser accepts them await app.register(fastifyStatic, { root: clientDistPath, wildcard: false, preCompressed: true, }); ``` ## 2. Immutable-кэш строго для хэшированных ассетов Важно: `immutable` нельзя вешать на всю статику — в `dist/` кроме `/assets/*` лежат **нехэшированные** файлы (`/locales/*.json`, `/vad/*`, иконки, manifest), которые меняются между деплоями. Скоупить через отдельную регистрацию по префиксу либо `setHeaders`: ```ts await app.register(fastifyStatic, { root: clientDistPath, wildcard: false, preCompressed: true, setHeaders: (res, path) => { // content-hashed files only: cache forever if (path.includes("/assets/")) { res.setHeader("cache-control", "public, max-age=31536000, immutable"); } // index.html must be revalidated on every load (config injection, deploys) if (path.endsWith("index.html")) { res.setHeader("cache-control", "no-cache, no-store, must-revalidate"); } // everything else (locales, vad, icons) keeps default revalidation (etag/last-modified) }, }); ``` Результат: повторный заход = скачивание только `index.html` (пара КБ) + API. ## 3. `@fastify/compress` для динамических ответов `app.register(compress)` в main.ts — сжимает API-JSON (контент страницы, дерево, комментарии) и переписанный share-SEO HTML ([share-seo.controller.ts](apps/server/src/core/share/share-seo.controller.ts)). Статику не трогает — она уже отдаётся pre-compressed. Проверить взаимодействие с SSE/stream-ответами AI-чата (исключить их из сжатия, см. крайние случаи). ## 4. Документировать реверс-прокси с HTTP/2/3 (опционально) Для инстансов за caddy/nginx/traefik — включённый HTTP/2 убирает лимит 6 соединений. Добавить в README/compose-пример опциональный caddy-фронт (авто-TLS + h2). Если прокси уже есть и на нём включён brotli — пп. 1/3 частично закрываются конфигом прокси, но `preCompressed` всё равно выгоднее (нулевой CPU на каждом запросе). # Крайние случаи - **`index.html` переписывается при старте сервера** ([static.module.ts#L55-L64](apps/server/src/integrations/static/static.module.ts#L55-L64): инъекция `window.CONFIG` через index-template) — пре-сжатую копию index.html создавать нельзя (будет отдаваться устаревший шаблон без конфига). Исключить из плагина сжатия (см. сниппет) и убедиться, что прямой запрос `/index.html` через fastifyStatic не находит `.br`-соседа. - **Share-SEO/alias контроллеры** читают и переписывают index.html на лету — их ответы сжимает только @fastify/compress (п. 3), не preCompressed; проверить, что мета-инъекция не ломается. - **SSE/streaming AI-чата**: сжатие буферизует поток — исключить `text/event-stream` (у @fastify/compress есть настройка) либо убедиться, что дефолт не трогает эти маршруты. - **Локали и vad-ассеты не хэшированы** — им нельзя `immutable` (см. п. 2), иначе после деплоя пользователи застрянут на старых переводах/модели. - **Attachments** ([attachment.controller.ts](apps/server/src/core/attachment/attachment.controller.ts)) идут отдельным путём со своими заголовками — не трогаем; сжимать бинарники (изображения) бессмысленно, исключить по mime, если compress регистрируется глобально. - **Рост образа Docker**: `.br`+`.gz` копии увеличат слой с client/dist (~+30–40%, дешёво относительно выигрыша). - **Слабый CPU сервера**: именно поэтому предпочесть preCompressed, а @fastify/compress ограничить JSON/HTML (у бинарников и так low ratio). # Тесты / проверка - `curl -H "Accept-Encoding: br" -sI https://<host>/assets/index-*.js` → `content-encoding: br` + `cache-control: …immutable`. - `curl -sI …/index.html` → нет `content-encoding` от preCompressed (или корректный динамический), `no-cache`, `window.CONFIG` присутствует в теле. - `curl -H "Accept-Encoding: br" -sI …/api/pages/...` → сжатый JSON. - Локали: после «деплоя» (замены файла) клиент получает новый перевод без hard-refresh. - AI-чат стриминг работает (SSE не буферизуется). - Lighthouse/DevTools на профиле Slow 4G: время до интерактивности до/после; повторная загрузка — только index.html + API в Network-панели. # Вне скоупа - Уменьшение самого eager-бандла (route splitting, lazy katex/lowlight/drawio) — #342 (слой A). - Латентность печати — #343; фоновые ре-рендеры — #344; панель комментариев — #340. - Service worker / offline-кэш повторных загрузок — приземляется в PR #120 (unified service worker); настоящая ишью лечит холодную первую загрузку, SW добьёт повторные. - CDN для статики — отдельное инфра-решение, не требуется для основного выигрыша. # План работ 1. `vite-plugin-compression2` (br+gz, exclude index.html) + `preCompressed: true` в static.module.ts. 2. Кэш-заголовки: immutable для `/assets/*`, no-cache для index.html, дефолт для остального (п. 2). 3. `@fastify/compress` для API/HTML с исключением SSE и бинарных mime (п. 3). 4. Проверка share-SEO путей и стриминга AI-чата. 5. README/compose: рекомендация HTTP/2-прокси (опционально). 6. Замеры до/после: `curl -I` матрица + Lighthouse Slow 4G + повторная загрузка.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#346