feat: добавить базовые сервисы image-platform

- добавлены backend, admin, gateway и worker skeleton
- добавлены Drizzle schema, database package и initial migration
- добавлены shared packages для RabbitMQ topology и S3 helpers
- обновлены dev-инфраструктура, env example, scripts и dependencies
- обновлена документация под versioned image URLs и read-through flow
This commit is contained in:
2026-05-05 09:59:21 +03:00
parent 37592c8b81
commit bcadb85a83
66 changed files with 8698 additions and 213 deletions

View File

@@ -1,124 +0,0 @@
# Черновик API Контракта
Это не реализация API, а фиксация будущего контракта для NestJS backend.
Backend отдаёт JSON, metadata, statuses и URLs. Он не должен проксировать image bytes на каждый обычный запрос.
## Allowed Hosts
```text
GET /allowed-hosts
POST /allowed-hosts
PATCH /allowed-hosts/:id
DELETE /allowed-hosts/:id
```
## Assets
```text
GET /assets
POST /assets
GET /assets/:id
DELETE /assets/:id
```
`POST /assets` request:
```json
{
"sourceUrl": "https://example.com/photo.jpg"
}
```
Responsibilities:
- validate source URL;
- check `allowed_image_hosts`;
- create or reuse `image_assets` row;
- optionally save original to S3 later.
## Variants
```text
GET /assets/:id/variants
POST /assets/:id/variants
POST /variants/:id/regenerate
DELETE /variants/:id
```
`POST /assets/:id/variants` request:
```json
{
"preset": "card",
"format": "webp",
"width": 640
}
```
Response if ready:
```json
{
"id": "variant_123",
"status": "ready",
"url": "http://localhost:8888/images/asset_123/w640_q80_card.webp"
}
```
Response if generation is async:
```json
{
"id": "variant_123",
"status": "pending",
"url": null
}
```
## Image URLs For UI
Для UI нужен endpoint, который возвращает готовый набор URLs для `<picture>`/`srcset`.
```text
GET /assets/:id/picture?preset=card
```
Example response:
```json
{
"assetId": "asset_123",
"preset": "card",
"sources": [
{
"type": "image/avif",
"srcset": "http://localhost:8888/images/asset_123/w320_card.avif 320w, http://localhost:8888/images/asset_123/w640_card.avif 640w"
},
{
"type": "image/webp",
"srcset": "http://localhost:8888/images/asset_123/w320_card.webp 320w, http://localhost:8888/images/asset_123/w640_card.webp 640w"
}
],
"fallback": {
"src": "http://localhost:8888/images/asset_123/w640_card.jpg",
"width": 640,
"height": null
}
}
```
## Worker Lifecycle
Первый MVP может генерировать sync на request. Если генерация тяжёлая, variant создаётся как `pending`, а worker обрабатывает job.
PostgreSQL может выступить первой очередью:
```text
SELECT * FROM image_variants
WHERE status = 'pending'
FOR UPDATE SKIP LOCKED
LIMIT 1
```
Позже можно добавить Redis/Valkey или отдельную queue, если PostgreSQL станет узким местом.

View File

@@ -12,24 +12,49 @@
|---|---|---|
| PostgreSQL | сейчас | Источник правды для assets, variants, hosts, statuses |
| S3/MinIO | сейчас | Хранилище originals и generated variants |
| API | позже | JSON API, admin operations, validation, orchestration |
| Worker | позже | Генерация variants, upload в S3, update PostgreSQL |
| Admin UI | позже | Управление hosts/assets/variants/presets |
| Gateway | позже | Caddy/Souin hot cache и delivery layer |
| Backend | сейчас | NestJS JSON API, Swagger, PostgreSQL/S3/RabbitMQ orchestration |
| Worker | позже | RabbitMQ consumer, imgproxy processing, upload в S3, update PostgreSQL |
| Admin UI | сейчас | React/Vite UI для будущего управления hosts/assets/variants/presets |
| Gateway | сейчас | Fastify public image origin, L1 memory cache, root routing, без DB/S3 доступа |
| RabbitMQ | сейчас | Очередь задач генерации variants |
| imgproxy | external | CPU-heavy image processing |
## Архитектурное решение
Нужное поведение - Cloudinary-like: публичный URL изображения сам запускает read-through pipeline, если variant ещё не создан.
`image-platform` строится ради совместимости с `next/image` как custom loader provider. Next.js application не должен заранее вызывать API, ждать генерацию и затем подставлять S3 URL. Он должен передавать `src`, `width` и `quality` в loader, а loader должен вернуть стабильный URL нашего image origin.
Gateway поэтому является обязательной частью public delivery path, а не опциональным кешем.
## Целевая delivery схема
```text
client
-> CDN optional
-> gateway Caddy/Souin
-> S3 ready variant
-> generator fallback
-> Fastify gateway L1 memory cache
-> NestJS backend
-> PostgreSQL + S3 ready variant
-> RabbitMQ -> worker
-> external imgproxy
-> source image
```
Read-through flow:
```text
1. client запрашивает /images/{assetId}/v{version}/{preset}?w=640&q=80&f=auto
2. CDN HIT -> ответ сразу
3. Gateway L1 HIT -> ответ сразу
4. Gateway L1 MISS -> Gateway вызывает Backend internal ensure endpoint
5. Backend проверяет PostgreSQL/S3
6. S3 HIT -> Backend стримит bytes Gateway, Gateway кладёт ответ в L1
7. S3 MISS -> Backend ставит RabbitMQ job
8. Worker вызывает external imgproxy и сохраняет результат в S3
9. Worker обновляет PostgreSQL, Backend отдаёт готовые bytes Gateway
10. Gateway кладёт ответ в L1 и отдаёт клиенту
```
## Разделение ответственности
PostgreSQL отвечает на вопросы:
@@ -49,29 +74,45 @@ S3 хранит байты:
Gateway отдаёт картинки:
- hot cache HIT - сразу из Souin;
- cache MISS - из S3;
- S3 MISS - через generator fallback.
- L1 memory HIT - сразу из памяти;
- L1 memory MISS - вызывает Backend;
- не имеет доступа к PostgreSQL, S3 и RabbitMQ.
Backend не должен проксировать картинки на каждый обычный запрос. Он отдаёт JSON, статусы и URLs.
Backend управляет процессами: PostgreSQL, S3 read, RabbitMQ enqueue, ожидание ready variant. Worker выполняет CPU-heavy generation через external imgproxy и пишет результат в S3.
## URL модель
Публичные URL должны быть стабильными и не раскрывать source URL:
Публичные URL должны быть стабильными и не раскрывать source URL. Для Next/image provider основной URL должен принимать width/quality из loader:
```text
/images/{assetId}/{variantHash}.{format}
/images/{assetId}/v{version}/{preset}?w={width}&q={quality}&f=auto
```
Примеры:
```text
/images/asset_123/w640_q80_cfill.avif
/images/asset_123/w640_q80_cfill.webp
/images/asset_123/w640_q80_cfill.jpg
/images/asset_123/v4/card?w=640&q=80&f=auto
/images/asset_123/v4/hero?w=1920&q=80&f=auto
```
Формат лучше делать явным в URL и отдавать через `<picture>`/`srcset`, а не выбирать по `Accept` header. Так CDN/S3/Gateway cache остаётся предсказуемым.
`v{version}` берётся из `image_assets.current_version` и меняется при обновлении source image. Это даёт immutable cache без purge старых CDN/L1/S3 keys.
`f=auto` нужен для совместимости с `next/image` custom loader: Next передаёт в loader `src`, `width` и `quality`, но не выбирает AVIF/WebP сам при custom loader. Image origin должен выбрать формат по `Accept` header, как Cloudinary `f_auto`.
Из-за `f=auto` обязательно:
- S3 key должен включать фактически выбранный формат;
- response должен выставлять `Vary: Accept`;
- CDN и Gateway L1 cache должны учитывать `Accept`;
- response должен выставлять `Cache-Control: public, max-age=31536000, immutable` для versioned assets.
Для ручного `<picture>`/`srcset` можно добавить явный формат позже:
```text
/images/{assetId}/v{version}/{preset}?w=640&q=80&f=avif
/images/{assetId}/v{version}/{preset}?w=640&q=80&f=webp
/images/{assetId}/v{version}/{preset}?w=640&q=80&f=jpg
```
## External imgproxy

View File

@@ -0,0 +1,224 @@
# Черновик Backend Контракта
Это черновик будущего бизнес-контракта для NestJS backend. Сейчас реализованы system endpoints, Swagger и placeholder для internal image ensure.
## System
```text
GET /api/health
GET /docs
GET /docs-json
```
NestJS backend отдаёт JSON, metadata, statuses и URLs. Public image origin находится в Fastify Gateway. Backend владеет PostgreSQL, S3 orchestration и RabbitMQ jobs.
## Internal Image Ensure
Этот internal endpoint вызывается Gateway на L1 miss. Gateway не ходит в DB/S3 напрямую.
```text
POST /api/internal/images/ensure
```
Текущий статус реализации - endpoint зарезервирован и возвращает `501 Not Implemented`. Gateway `/images/*` пока тоже возвращает placeholder `501 Not Implemented`.
Request body:
```json
{
"assetId": "asset_123",
"version": 4,
"preset": "card",
"width": 640,
"quality": 80,
"format": "webp"
}
```
Query params:
| Param | Описание |
|---|---|
| `w` | целевая ширина, должна быть разрешена preset или округлена до ближайшей разрешённой |
| `q` | качество, должно быть из allowlist качества |
| `f` | `auto`, `avif`, `webp`, `jpg`; для Next/image по умолчанию `auto` |
Responsibilities:
- проверить `assetId` и `preset`;
- вычислить deterministic `variantHash`;
- проверить PostgreSQL и S3;
- если variant готов в S3, вернуть bytes или stream metadata для Gateway;
- если variant отсутствует, создать/переиспользовать variant row;
- поставить `generate-variant` job в RabbitMQ;
- дождаться `ready` до timeout, чтобы первый `next/image` request мог получить картинку;
- вернуть image response или metadata для Gateway.
Response headers:
```http
Content-Type: image/avif | image/webp | image/jpeg
Cache-Control: public, max-age=31536000, immutable
Vary: Accept
ETag: "..."
```
Ошибки:
| Status | Когда |
|---|---|
| `400` | некорректные query params |
| `404` | asset или preset не найден |
| `409` | variant уже генерируется и sync ожидание отключено |
| `422` | source image нельзя обработать |
| `502` | external imgproxy недоступен |
Public endpoint реализуется в Fastify Gateway, internal ensure endpoint - в Backend:
```text
client -> CDN -> Gateway /images/* -> Backend ensure -> RabbitMQ -> Worker -> imgproxy -> S3
```
Важно: `/images/*` не должен жить в NestJS `/api`. Gateway остаётся public image origin, Backend остаётся управлением процессов.
Public image URL versioned: `/images/{assetId}/v{version}/{preset}?w={width}&q={quality}&f=auto`.
## Allowed Hosts
```text
GET /allowed-hosts
POST /allowed-hosts
PATCH /allowed-hosts/:id
DELETE /allowed-hosts/:id
```
## Assets
```text
GET /assets
POST /assets
GET /assets/:id
DELETE /assets/:id
```
`POST /assets` request:
```json
{
"sourceUrl": "https://example.com/photo.jpg"
}
```
Responsibilities:
- validate source URL;
- check `allowed_image_hosts`;
- create or reuse `image_assets` row;
- optionally save original to S3 later.
## Variants
```text
GET /assets/:id/variants
POST /assets/:id/variants
POST /variants/:id/regenerate
DELETE /variants/:id
```
`POST /assets/:id/variants` request:
```json
{
"preset": "card",
"format": "webp",
"width": 640
}
```
Response if ready:
```json
{
"id": "variant_123",
"status": "ready",
"url": "http://localhost:8888/images/asset_123/v4/card?w=640&q=80&f=webp"
}
```
Response if generation is async:
```json
{
"id": "variant_123",
"status": "pending",
"url": null
}
```
## Image URLs For UI
Для ручного UI можно добавить endpoint, который возвращает готовый набор URLs для `<picture>`/`srcset`. Для `next/image` основным контрактом остаётся custom loader из `docs/next-image-provider.md`.
```text
GET /assets/:id/picture?preset=card
```
Example response:
```json
{
"assetId": "asset_123",
"preset": "card",
"sources": [
{
"type": "image/avif",
"srcset": "http://localhost:8888/images/asset_123/v4/card?w=320&q=80&f=avif 320w, http://localhost:8888/images/asset_123/v4/card?w=640&q=80&f=avif 640w"
},
{
"type": "image/webp",
"srcset": "http://localhost:8888/images/asset_123/v4/card?w=320&q=80&f=webp 320w, http://localhost:8888/images/asset_123/v4/card?w=640&q=80&f=webp 640w"
}
],
"fallback": {
"src": "http://localhost:8888/images/asset_123/v4/card?w=640&q=80&f=jpg",
"width": 640,
"height": null
}
}
```
## Worker Lifecycle
Worker выполняет задачи из RabbitMQ. Задачи создаёт Backend.
RabbitMQ topology:
```text
exchange: image-platform.jobs
queue: image.generate-variant
dead-letter exchange: image-platform.jobs.dlx
dead-letter queue: image.generate-variant.dlq
```
Job payload должен быть минимальным:
```json
{
"jobId": "job_123",
"variantId": "variant_123"
}
```
Worker читает детали variant из PostgreSQL, вызывает imgproxy, пишет результат в S3 и обновляет status в PostgreSQL.
Если генерация тяжёлая или не успела завершиться до timeout, Backend может вернуть Gateway `504`, а job продолжит выполняться/retry по очереди.
PostgreSQL может выступить первой очередью:
```text
SELECT * FROM image_variants
WHERE status = 'pending'
FOR UPDATE SKIP LOCKED
LIMIT 1
```
PostgreSQL-backed очередь не используется как основной механизм: для jobs выбран RabbitMQ.

View File

@@ -1,16 +1,16 @@
# Черновик Data Model
# Data Model
Это черновик для будущих миграций PostgreSQL. Реальные таблицы добавим вместе с API.
Текущая PostgreSQL модель описана в `packages/database/src/schema.ts` и миграциях Drizzle в `packages/database/drizzle`.
## allowed_image_hosts
```text
id
hostname
enabled
description nullable
created_at
updated_at
id uuid pk default gen_random_uuid()
hostname text not null unique
enabled boolean not null default true
description text nullable
created_at timestamptz not null default now()
updated_at timestamptz not null default now()
```
Правила normalization:
@@ -26,53 +26,119 @@ updated_at
## image_assets
```text
id
source_url
source_host
source_hash
original_s3_key nullable
status
width nullable
height nullable
content_type nullable
size_bytes nullable
created_at
updated_at
id uuid pk default gen_random_uuid()
public_id text not null unique
current_version integer not null default 1
status asset_status not null default active
created_at timestamptz not null default now()
updated_at timestamptz not null default now()
```
`public_id` - стабильный идентификатор в public URL. `current_version` указывает активную версию source image и используется для cache invalidation без purge.
## image_asset_versions
```text
id uuid pk default gen_random_uuid()
asset_id uuid not null references image_assets(id) on delete cascade
version integer not null
source_url text not null
source_host text not null
source_hash text not null
original_s3_key text nullable
width integer nullable
height integer nullable
content_type text nullable
size_bytes bigint nullable
created_at timestamptz not null default now()
```
Каждое изменение source image создаёт новую версию. Старые versioned URLs остаются immutable, новые клиенты получают URL с новым `v{version}`.
## image_variants
```text
id
asset_id
preset
variant_hash
format
width
height nullable
quality
s3_key
status: pending | processing | ready | failed
size_bytes nullable
error nullable
created_at
updated_at
last_accessed_at nullable
id uuid pk default gen_random_uuid()
asset_id uuid not null references image_assets(id) on delete cascade
asset_version_id uuid not null references image_asset_versions(id) on delete cascade
asset_version integer not null
preset text not null
variant_hash text not null unique
requested_format requested_format not null default auto
format variant_format not null
width integer not null
height integer nullable
quality integer not null
s3_key text not null unique
content_type text nullable
etag text nullable
status variant_status not null default pending
size_bytes bigint nullable
error text nullable
attempt_count integer not null default 0
last_accessed_at timestamptz nullable
created_at timestamptz not null default now()
updated_at timestamptz not null default now()
```
`requested_format` хранит то, что запросил клиент (`auto`, `avif`, `webp`, `jpg`, `png`). `format` хранит фактический output format после negotiation по `Accept`.
## Enums
```text
asset_status: active | disabled | deleted
requested_format: auto | avif | webp | jpg | png
variant_format: avif | webp | jpg | png
variant_status: pending | processing | ready | failed
```
## Unique constraints
```text
allowed_image_hosts(hostname)
image_assets(source_hash)
image_variants(asset_id, variant_hash, format)
image_assets(public_id)
image_asset_versions(asset_id, version)
image_variants(asset_id, asset_version, preset, width, quality, format)
image_variants(s3_key)
image_variants(variant_hash)
```
Индексы:
```text
image_asset_versions(source_hash)
image_variants(status)
```
## S3 layout
```text
originals/{assetId}/source
variants/{assetId}/{variantHash}.{format}
originals/{assetId}/v{version}/source
variants/{assetId}/v{version}/{variantHash}.{format}
```
`variantHash` должен включать:
- `assetId`;
- `assetVersion`;
- `preset`;
- normalized width;
- normalized quality;
- фактический output format;
- параметры transform, влияющие на bytes.
Для `f=auto` в public URL в S3 всё равно пишется фактический формат:
```text
variants/asset_123/v4/card_w640_q80_avif.avif
variants/asset_123/v4/card_w640_q80_webp.webp
variants/asset_123/v4/card_w640_q80_jpg.jpg
```
Public URL также versioned:
```text
/images/{assetId}/v{version}/{preset}?w={width}&q={quality}&f=auto
```
## Presets

View File

@@ -2,15 +2,18 @@
## Принцип
В Docker запускаем стабильную инфраструктуру. Кодовые сервисы позже запускаем нодой с hot reload.
В Docker запускаем стабильную инфраструктуру. Кодовые сервисы запускаем нодой с hot reload.
Сейчас в Docker есть только:
- PostgreSQL;
- MinIO;
- MinIO bucket init.
- MinIO bucket init;
- imgproxy dev instance;
- RabbitMQ.
`api`, `worker`, `admin` и `gateway` пока не созданы.
`backend` уже создан как NestJS app. `admin` уже создан как чистый Vite React TS app. `gateway` уже создан как Fastify app. `worker` уже создан как RabbitMQ skeleton.
Gateway обязателен для Cloudinary-like поведения и интеграции с `next/image`.
## Запуск инфраструктуры
@@ -45,40 +48,139 @@ pnpm infra:logs
| PostgreSQL | `localhost:5433` |
| MinIO API | `http://localhost:9000` |
| MinIO Console | `http://localhost:9001` |
| imgproxy | `http://localhost:18080` |
| RabbitMQ | `amqp://localhost:5672` |
| RabbitMQ Management | `http://localhost:15672` |
| Backend API | `http://localhost:3001/api` |
| Swagger | `http://localhost:3001/docs` |
| Admin | `http://localhost:5173` |
| Gateway | `http://localhost:8888` |
Если `localhost:8888` занят старым `image-gateway`, остановите старый stack или задайте `GATEWAY_PORT=8890` в `.env`.
## Backend
Запустить NestJS backend:
```bash
pnpm backend:dev
```
Проверки:
```bash
curl http://localhost:3001/api/health
open http://localhost:3001/docs
```
## Database
Drizzle schema живёт в `packages/database/src/schema.ts`, миграции - в `packages/database/drizzle`.
Сгенерировать миграцию после изменения schema:
```bash
pnpm db:generate
```
Применить миграции к локальному PostgreSQL:
```bash
pnpm db:migrate
```
Открыть Drizzle Studio из корня проекта:
```bash
pnpm db:studio
```
Проверить database package:
```bash
pnpm db:typecheck
pnpm db:build
```
## Admin
Запустить React/Vite admin:
```bash
pnpm admin:dev
```
Открыть:
```bash
open http://localhost:5173
```
## Gateway
Gateway запускается нодой:
```bash
pnpm gateway:dev
```
Проверить gateway health:
```bash
curl http://localhost:8888/health
```
Проверить placeholder image origin route:
```bash
curl -i "http://localhost:8888/images/asset_123/v4/card?w=640&q=80&f=auto"
```
Ожидаемый текущий статус - `501`, потому что S3/imgproxy генерация ещё не реализована.
## Worker
Worker запускается нодой и сейчас только объявляет RabbitMQ topology, слушает `image.generate-variant` и отправляет полученные jobs в DLQ, потому что генерация ещё не реализована.
```bash
pnpm worker:dev
```
Проверить worker package без запуска consumer:
```bash
pnpm worker:typecheck
pnpm worker:build
```
## Будущий dev flow
Когда появятся приложения:
Текущая и будущая схема:
```text
React/Vite admin localhost:5173
-> NestJS API localhost:3001
-> NestJS backend localhost:3001
-> PostgreSQL localhost:5433
-> MinIO localhost:9000
-> RabbitMQ localhost:5672
worker node process
-> PostgreSQL
-> MinIO
-> external imgproxy
-> RabbitMQ
-> imgproxy localhost:18080
gateway Caddy/Souin localhost:8888
-> S3/MinIO ready variant
-> API/generator fallback on host.docker.internal:3001
Fastify gateway localhost:8888
-> L1 memory cache
-> Backend internal ensure endpoint
```
Для Linux gateway container должен видеть host services через:
## imgproxy для разработки
```yaml
extra_hosts:
- "host.docker.internal:host-gateway"
```
## External imgproxy для разработки
`imgproxy` не входит в `image-platform` stack. Для локальной разработки можно использовать любой внешний endpoint и прописать его в `.env`:
В dev compose поднимается локальный `imgproxy`, опубликованный только на `127.0.0.1:18080`:
```env
IMGPROXY_UPSTREAM=http://localhost:18080
```
Если нужен локальный standalone imgproxy, его можно запустить отдельно вне этого compose stack. Он остаётся внешней зависимостью платформы.
Для production `imgproxy` всё равно рассматривается как внешняя зависимость и может жить на отдельном мощном сервере.

View File

@@ -63,7 +63,7 @@ Final signed URL:
## Security rules
- Не отдавать `IMGPROXY_KEY` и `IMGPROXY_SALT` в браузер.
- Source URL валидировать в API/worker.
- Source URL валидировать в Backend/worker.
- Разрешать только `http` и `https`.
- Запрещать localhost, private IP, loopback, link-local.
- Source host должен быть enabled в `allowed_image_hosts`.

105
docs/next-image-provider.md Normal file
View File

@@ -0,0 +1,105 @@
# Next/image Provider
`image-platform` должен работать как custom image provider для `next/image`.
## Next.js contract
Next.js custom loader получает только:
- `src`;
- `width`;
- `quality`.
Поэтому public image URL должен принимать эти параметры напрямую и сам запускать read-through генерацию при miss.
## Loader config
В Next.js приложении используется `loaderFile`:
```js
// next.config.js
module.exports = {
images: {
loader: "custom",
loaderFile: "./src/image-platform-loader.js",
qualities: [60, 75, 80, 90],
},
}
```
Пример loader:
```js
"use client"
const imageBaseUrl = process.env.NEXT_PUBLIC_IMAGE_PLATFORM_URL
export default function imagePlatformLoader({ src, width, quality }) {
const normalizedSrc = src.startsWith("/") ? src.slice(1) : src
const q = quality || 80
return `${imageBaseUrl}/images/${normalizedSrc}?w=${width}&q=${q}&f=auto`
}
```
Пример использования:
```tsx
import Image from "next/image"
export function ProductCard() {
return <Image src="asset_123/v4/card" width={640} height={420} alt="Product" />
}
```
## Public URL
```text
GET /images/{assetId}/v{version}/{preset}?w={width}&q={quality}&f=auto
```
Сейчас route уже зарезервирован в Fastify Gateway, но возвращает placeholder `501`, пока не реализованы PostgreSQL/S3/imgproxy read-through операции.
Пример:
```text
https://img.example.com/images/asset_123/v4/card?w=640&q=80&f=auto
```
`src` не должен быть source URL. Это должен быть versioned platform identifier, например `asset_123/v4/card`. Source URL хранится в PostgreSQL и не раскрывается в public image URL.
`v{version}` меняется при обновлении source image. Старые URL можно кэшировать как immutable без purge.
## Format auto
`f=auto` выбирает output format по `Accept` header:
1. `image/avif`, если клиент поддерживает AVIF и preset разрешает AVIF.
2. `image/webp`, если клиент поддерживает WebP и preset разрешает WebP.
3. `image/jpeg` или original fallback.
Для auto format обязательны headers:
```http
Vary: Accept
Cache-Control: public, max-age=31536000, immutable
Content-Type: image/avif | image/webp | image/jpeg
```
CDN и Gateway L1 cache должны учитывать `Vary: Accept`, иначе можно отдать AVIF клиенту без AVIF support.
## Read-through behavior
```text
client -> CDN -> Fastify gateway -> L1 memory -> Backend -> RabbitMQ -> Worker -> imgproxy -> S3
```
Поведение:
- CDN HIT: backend не вызывается.
- Gateway L1 HIT: backend не вызывается.
- Gateway L1 MISS: Gateway вызывает Backend internal ensure endpoint.
- S3 HIT: Backend отдаёт bytes Gateway, Gateway кладёт result в L1.
- S3 MISS: Backend ставит RabbitMQ job, Worker генерирует variant через external imgproxy, сохраняет в S3, обновляет PostgreSQL, Backend возвращает bytes Gateway.
Так достигается Cloudinary-like поведение: первый запрос создаёт derived asset, следующие запросы отдаются из cache/storage.