Кешування та ETag
Бекенд має два шари кешування: in-memory кеш статики з ETag/Cache-Control для браузера та кілька процесних кешів у пам'яті. JSON API за замовчуванням не кешується: відповіді мають Cache-Control: no-store. Як увімкнути роздачу статики (SERVE_STATIC, CDN_URL), описано в Розгортанні. Як запит проходить через сервер, описано в Життєвому циклі запиту.
Статика: кеш у пам'яті
Усе реалізовано в static-server.ts:
- Коли: лише при
appConfig.serveStatic. ФункціяcacheStaticSource()один раз на старті рекурсивно читає./public(приAPP_ENV=manual-test—./public-test) відносноprocess.cwd(). Файли й каталоги, чиї імена починаються з., пропускаються. - Що: беруться лише файли з розширеннями з whitelist
ALLOWED_STATIC_EXTENSIONS(html, css, js, mjs, json, webmanifest, зображення, шрифти, аудіо/відео). Решта ігнорується. Бінарні файли зберігаються якBuffer, текстові якutf8-рядок (isBinaryExtension). - Ліміти пам'яті: у RAM кладуться файли до
STATIC_MAX_MEMORY_FILE_SIZE(за замовчуванням 1 MiB), доки сума (разом зі стислими варіантами) не перевищитьSTATIC_MAX_MEMORY_TOTAL(64 MiB). Решта віддається стрімінгом з диска (fs.createReadStreamз backpressure uWS:tryEnd/onWritable) — їхній вміст на старті не читається.index.htmlзавжди в пам'яті: його віддає SPA fallback. На старті в лог пишетьсяStatic files cachedзfiles,memoryBytesіstreamedFromDisk. Зміни на диску після старту не підхоплюються, потрібен рестарт. - Маршрути: у
server.ts(configureHttp) для кожного закешованого шляху реєструється окремийserver.get(key, …)→staticCacheHandler. Шлях/отримує копію/index.html. - Стиснення: для текстових типів (
html,js,mjs,css,svg,json,webmanifest,txt,xml) від 1 KiB сервер на старті готує варіантиbr(якість 9) іgzip(рівень 9) і тримає їх у пам'яті, якщо вони менші за оригінал. Варіант обирається заAccept-Encoding(br→gzip→ без стиснення;q=0вимикає кодування) зContent-Encoding; відповідь на такий файл завжди маєVary: Accept-Encoding, зокрема й304. Наприклад, головний бандл 873 428 B віддається як 251 055 B уbr. Файли зі стрімінгу не стискаються.Content-Lengthстрімінгу — розмір файлу на момент старту: якщо файл з тих пір став коротшим (перезбіркаpublic/під працюючим сервером), відповідь закривається зwarn, а не висить.
ETag
computeEtag рахує SHA-1 від вмісту файлу в base64url і бере результат у лапки (strong ETag, наприклад "q1w2…"). Хеші обчислюються один раз на старті й зберігаються в записі кешу. Для файлів зі стрімінгу ETag слабкий, з розміру й mtime: W/"<size hex>-<mtime hex>". Коментар у коді пояснює, чому validator має залежати від вмісту, а не від довжини: після ребілду index.html зазвичай змінює лише хеші асетів однакової довжини.
If-None-Match розбирає matchesIfNoneMatch за RFC 9110 §13.1.2: * збігається з будь-яким файлом, список через кому перевіряється поелементно, порівняння слабке (W/"x" = "x"). Збіг → 304 без тіла.
Cache-Control за типом файлу
| Тип | Cache-Control | Інші заголовки |
|---|---|---|
HTML (.html, /, SPA fallback) | public, max-age=0, must-revalidate (HTML_CACHE_CONTROL) | ETag, content-type, setSecurityHeaders (nosniff, X-Frame-Options, Referrer-Policy, HSTS) |
/assets/* (імена з хешем від Vite) | public, max-age=31536000, immutable | ETag, Content-Type, X-Content-Type-Options: nosniff, Vary / Content-Encoding для стислих |
Усе інше з whitelist (manifest.json, service-worker.js, іконки, аудіо, …) | no-cache — браузер кешує, але щоразу перевіряє ETag (дешевий 304) | ті самі |
MIME-тип береться з MIME_TYPES за розширенням. Якщо розширення немає в мапі, береться default.
SPA fallback і безпека
Запити, які не збіглися з жодним закешованим шляхом, потрапляють у server.any('/*'). Якщо це GET поза /${pathPrefix}/ і serveStatic увімкнено, викликається staticHandler:
isUrlSafeвідхиляє URL із..,\0,%2e%2e,%00→400 Bad Request. Детальніше про захист — у Безпеці.- URL без крапки отримує
index.htmlчерез той самийhandleHtmlRequest, тобто з ETag іmust-revalidate. - URL із крапкою, якого немає в кеші, отримує
404з порожнім тілом.
CSP
Генерація заголовка CSP у static-server.ts закоментована (setCspHeader), тому HTML віддається без Content-Security-Policy. Це проблема 1 у docs/plan/code-issues-found-during-docs.md.
API-відповіді
sendResponse у server.ts виставляє content-type: application/json, security-заголовки, заголовки й cookies з responseData і CORS. Кожна API-відповідь — і з sendResponse, і помилки з sendErrorResponse (422, 429, 500 тощо) — отримує Cache-Control: no-store (setApiCacheHeader у http-response-handlers.ts): відповіді містять сесійні й персональні дані, тож браузер і проміжні проксі не повинні їх зберігати. Якщо контролер сам задав Cache-Control у responseData.headers (без огляду на регістр), його значення лишається, і no-store не додається. ETag на JSON-відповіді не ставиться. Статика має власні правила (вище).
CORS: якщо ENABLED_CORS=true, setCorsHeader пише Vary: Origin і Access-Control-Max-Age = maxAge з config/cors.ts (зараз 90 секунд, змінюється лише в коді). Отже, браузер кешує результат preflight на 90 с.
Інші кеші в пам'яті
- AI-промпти:
prompt-service.tsтримаєMap<PromptType, PromptRow>. Його заповнюєpromptService.load()на старті (index.ts), аupdate()перечитує змінений рядок із БД і одразу кладе в кеш. TTL —PROMPT_CACHE_TTL_MS(60 с): прострочений кеш відповідає поточним значенням і перечитує всі промпти у фоні (одне перечитування одночасно; при помилці лишаються старі значення). Кеш живе в кожному процесі окремо, тож зміна на одному інстансі потрапляє на інші із затримкою до TTL; pub/sub-інвалідації немає. - Сесії, throttle і токени в Redis — це стан, а не кеш. Див. Автентифікацію.
Фронтенд: Service Worker
service-worker.ts (збирається через vite.sw.config.ts) під час install прекешує список filesToCache: /, іконки, splash, аудіо. Для /assets/, /icons/, /audio/, .css тощо (isStaticAsset) він працює за стратегією cache-first. Ім'я кешу — BUILD_TIMESTAMP. Старі кеші видаляються на activate.
Перевірка через curl
Приклад (ілюстративний, значення умовні):
curl -I http://localhost:3000/index.html
# HTTP/1.1 200 OK
# content-type: text/html; charset=UTF-8
# Cache-Control: public, max-age=0, must-revalidate
# ETag: "3n0Yq…"
curl -I -H 'If-None-Match: "3n0Yq…"' http://localhost:3000/index.html
# HTTP/1.1 304 Not ModifiedПорт і MIME залежать від вашої конфігурації (Конфігурація).
Як змінити поведінку
- Значення
Cache-Controlзадані константами вstatic-server.ts(HTML_CACHE_CONTROL,IMMUTABLE_CACHE_CONTROL,REVALIDATE_CACHE_CONTROL); правило «immutableлише для/assets/» — уcacheControlFor. - Ліміти пам'яті —
STATIC_MAX_MEMORY_FILE_SIZE,STATIC_MAX_MEMORY_TOTAL(Конфігурація). - Whitelist розширень задається в
ALLOWED_STATIC_EXTENSIONS. - Dev: фронтенд віддає Vite dev-сервер,
SERVE_STATICзазвичайfalse. Prod: абоSERVE_STATIC=trueз каталогомpublicв образі (у Dockerfile змінна не задана, проблема 31), або окремий хостинг/CDN (CDN_URL). У випадку CDN заголовки кешування задає CDN. Див. Розгортання.
Історія
Раніше ETag будувався з довжини файлу й URL. Зараз це SHA1-хеш вмісту, порахований один раз при старті; чому так — у коментарі в static-server.ts. Нотатку etag-improvements.md з варіантами MD5 / mtime / CRC32 видалено у feat-009 (остання версія).