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:
@@ -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 станет узким местом.
|
||||
@@ -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
|
||||
|
||||
|
||||
224
docs/backend-contract-draft.md
Normal file
224
docs/backend-contract-draft.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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` всё равно рассматривается как внешняя зависимость и может жить на отдельном мощном сервере.
|
||||
|
||||
@@ -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
105
docs/next-image-provider.md
Normal 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.
|
||||
Reference in New Issue
Block a user