feat: добавить генерацию image variants
- добавлен shared config presets, custom transforms и allowlist hosts - реализованы Backend endpoints для assets, presets и variants - добавлена orchestration через PostgreSQL, RabbitMQ, S3 и worker - обновлён Gateway read-through flow с L1 cache и корректным Vary: Accept - добавлена миграция resize_mode для variants lookup - обновлены dev scripts, env template, lockfile и документация
This commit is contained in:
@@ -5,11 +5,12 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"start": "node dist/main.js",
|
||||
"dev": "node --env-file-if-exists=../../.env ./node_modules/tsx/dist/cli.mjs watch src/main.ts",
|
||||
"start": "node --env-file-if-exists=../../.env dist/main.js",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@image-platform/image-config": "workspace:*",
|
||||
"fastify": "^5.8.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export type GatewayConfig = {
|
||||
backendUpstream: URL
|
||||
host: string
|
||||
l1MaxEntries: number
|
||||
l1TtlMs: number
|
||||
port: number
|
||||
}
|
||||
|
||||
@@ -8,6 +10,8 @@ export function loadGatewayConfig(): GatewayConfig {
|
||||
return {
|
||||
backendUpstream: new URL(process.env.GATEWAY_BACKEND_UPSTREAM ?? "http://localhost:3001"),
|
||||
host: process.env.GATEWAY_HOST ?? "0.0.0.0",
|
||||
l1MaxEntries: parsePositiveInteger(process.env.GATEWAY_L1_MAX_ENTRIES, 256),
|
||||
l1TtlMs: parsePositiveInteger(process.env.GATEWAY_L1_TTL_MS, 10 * 60 * 1000),
|
||||
port: parsePort(process.env.GATEWAY_PORT, 8888),
|
||||
}
|
||||
}
|
||||
@@ -25,3 +29,13 @@ function parsePort(value: string | undefined, fallback: number) {
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string | undefined, fallback: number) {
|
||||
if (!value) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
52
apps/gateway/src/image-cache.ts
Normal file
52
apps/gateway/src/image-cache.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export type CachedImage = {
|
||||
body: Buffer
|
||||
cacheControl: string
|
||||
contentType: string
|
||||
etag: string | null
|
||||
}
|
||||
|
||||
export class ImageMemoryCache {
|
||||
private readonly entries = new Map<string, CachedImage & { expiresAt: number }>()
|
||||
|
||||
constructor(
|
||||
private readonly maxEntries: number,
|
||||
private readonly ttlMs: number,
|
||||
) {}
|
||||
|
||||
get(key: string): CachedImage | null {
|
||||
const entry = this.entries.get(key)
|
||||
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
this.entries.delete(key)
|
||||
return null
|
||||
}
|
||||
|
||||
this.entries.delete(key)
|
||||
this.entries.set(key, entry)
|
||||
|
||||
return {
|
||||
body: entry.body,
|
||||
cacheControl: entry.cacheControl,
|
||||
contentType: entry.contentType,
|
||||
etag: entry.etag,
|
||||
}
|
||||
}
|
||||
|
||||
set(key: string, image: CachedImage) {
|
||||
this.entries.set(key, { ...image, expiresAt: Date.now() + this.ttlMs })
|
||||
|
||||
while (this.entries.size > this.maxEntries) {
|
||||
const firstKey = this.entries.keys().next().value as string | undefined
|
||||
|
||||
if (!firstKey) {
|
||||
break
|
||||
}
|
||||
|
||||
this.entries.delete(firstKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
import Fastify from "fastify"
|
||||
import Fastify, { type FastifyReply } from "fastify"
|
||||
import {
|
||||
ImageTransformConfigError,
|
||||
normalizeImageTransform,
|
||||
parseBooleanFlag,
|
||||
selectFormatForAccept,
|
||||
type ActualImageFormat,
|
||||
type ResizeMode,
|
||||
} from "@image-platform/image-config"
|
||||
|
||||
import type { GatewayConfig } from "./config.js"
|
||||
import { ImageMemoryCache, type CachedImage } from "./image-cache.js"
|
||||
import { proxyToUpstream } from "./proxy.js"
|
||||
|
||||
export function createGatewayServer(config: GatewayConfig) {
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
})
|
||||
const imageCache = new ImageMemoryCache(config.l1MaxEntries, config.l1TtlMs)
|
||||
const allowCustomTransforms = parseBooleanFlag(process.env.IMAGE_ALLOW_CUSTOM_TRANSFORMS, false)
|
||||
|
||||
app.get("/health", async () => ({
|
||||
service: "image-platform-gateway",
|
||||
@@ -26,19 +37,113 @@ export function createGatewayServer(config: GatewayConfig) {
|
||||
}
|
||||
|
||||
const width = parseOptionalInteger(request.query.w)
|
||||
const height = parseOptionalNonNegativeInteger(request.query.h)
|
||||
const quality = parseOptionalInteger(request.query.q)
|
||||
const format = request.query.f ?? "auto"
|
||||
const resize = parseResizeMode(request.query.fit)
|
||||
|
||||
return reply.code(501).header("cache-control", "no-store").send({
|
||||
assetId: request.params.assetId,
|
||||
format,
|
||||
message: "image gateway read-through pipeline is not implemented yet",
|
||||
if (
|
||||
(request.query.w !== undefined && width === null) ||
|
||||
(request.query.h !== undefined && height === null) ||
|
||||
(request.query.q !== undefined && quality === null)
|
||||
) {
|
||||
return reply.code(400).send({
|
||||
message: "w, h and q query params must be positive integers",
|
||||
statusCode: 400,
|
||||
})
|
||||
}
|
||||
|
||||
if (request.query.fit !== undefined && resize === null) {
|
||||
return reply.code(400).send({
|
||||
message: "fit query param must be fit or fill",
|
||||
statusCode: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const format = selectFormat({
|
||||
acceptHeader: request.headers.accept,
|
||||
allowCustomTransforms,
|
||||
preset: request.params.preset,
|
||||
requestedFormat: request.query.f ?? "auto",
|
||||
})
|
||||
|
||||
if (!format.ok) {
|
||||
return reply.code(400).send({
|
||||
message: format.message,
|
||||
statusCode: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const transform = normalizeTransform({
|
||||
allowCustomTransforms,
|
||||
format: format.value.format,
|
||||
height,
|
||||
preset: request.params.preset,
|
||||
quality,
|
||||
status: "not_implemented",
|
||||
version,
|
||||
requestedFormat: format.value.requestedFormat,
|
||||
resize,
|
||||
width,
|
||||
})
|
||||
|
||||
if (!transform.ok) {
|
||||
return reply.code(400).send({
|
||||
message: transform.message,
|
||||
statusCode: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const cacheKey = buildImageCacheKey({
|
||||
assetId: request.params.assetId,
|
||||
format: transform.value.format,
|
||||
height: transform.value.height,
|
||||
preset: transform.value.preset,
|
||||
quality: transform.value.quality,
|
||||
resize: transform.value.resize,
|
||||
version,
|
||||
width: transform.value.width,
|
||||
})
|
||||
const cached = imageCache.get(cacheKey)
|
||||
const vary = transform.value.requestedFormat === "auto" ? "Accept" : null
|
||||
|
||||
if (cached) {
|
||||
return sendImage(reply, cached, "HIT", vary)
|
||||
}
|
||||
|
||||
const backendResponse = await fetch(new URL("/api/internal/images/ensure", config.backendUpstream), {
|
||||
body: JSON.stringify({
|
||||
assetId: request.params.assetId,
|
||||
format: transform.value.format,
|
||||
height: transform.value.height,
|
||||
preset: transform.value.preset,
|
||||
quality: transform.value.quality,
|
||||
requestedFormat: transform.value.requestedFormat,
|
||||
resize: transform.value.resize,
|
||||
version,
|
||||
width: transform.value.width,
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
})
|
||||
|
||||
if (!backendResponse.ok) {
|
||||
const body = await backendResponse.text()
|
||||
|
||||
return reply
|
||||
.code(backendResponse.status)
|
||||
.header("cache-control", "no-store")
|
||||
.header("content-type", backendResponse.headers.get("content-type") ?? "text/plain; charset=utf-8")
|
||||
.send(body)
|
||||
}
|
||||
|
||||
const image: CachedImage = {
|
||||
body: Buffer.from(await backendResponse.arrayBuffer()),
|
||||
cacheControl: backendResponse.headers.get("cache-control") ?? "public, max-age=31536000, immutable",
|
||||
contentType: backendResponse.headers.get("content-type") ?? contentTypeForFormat(transform.value.format),
|
||||
etag: backendResponse.headers.get("etag"),
|
||||
}
|
||||
|
||||
imageCache.set(cacheKey, image)
|
||||
|
||||
return sendImage(reply, image, "MISS", vary)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -59,10 +164,23 @@ export function createGatewayServer(config: GatewayConfig) {
|
||||
|
||||
type ImageQuery = {
|
||||
f?: string
|
||||
fit?: string
|
||||
h?: string
|
||||
q?: string
|
||||
w?: string
|
||||
}
|
||||
|
||||
type ImageCacheKeyInput = {
|
||||
assetId: string
|
||||
format: ActualImageFormat
|
||||
height: number
|
||||
preset: string
|
||||
quality: number
|
||||
resize: ResizeMode
|
||||
version: number
|
||||
width: number
|
||||
}
|
||||
|
||||
type ImageParams = {
|
||||
assetId: string
|
||||
preset: string
|
||||
@@ -84,7 +202,96 @@ function parseOptionalInteger(value: string | undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!/^\d+$/.test(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null
|
||||
}
|
||||
|
||||
function parseOptionalNonNegativeInteger(value: string | undefined) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!/^\d+$/.test(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
|
||||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null
|
||||
}
|
||||
|
||||
function parseResizeMode(value: string | undefined): ResizeMode | null {
|
||||
if (value === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
return value === "fit" || value === "fill" ? value : null
|
||||
}
|
||||
|
||||
function selectFormat(input: Parameters<typeof selectFormatForAccept>[0]) {
|
||||
try {
|
||||
return { ok: true as const, value: selectFormatForAccept(input) }
|
||||
} catch (error) {
|
||||
if (error instanceof ImageTransformConfigError) {
|
||||
return { message: error.message, ok: false as const }
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function buildImageCacheKey(input: ImageCacheKeyInput) {
|
||||
return [
|
||||
input.assetId,
|
||||
input.version,
|
||||
input.preset,
|
||||
input.width,
|
||||
input.height,
|
||||
input.resize,
|
||||
input.quality,
|
||||
input.format,
|
||||
].join(":")
|
||||
}
|
||||
|
||||
function normalizeTransform(input: Parameters<typeof normalizeImageTransform>[0]) {
|
||||
try {
|
||||
return { ok: true as const, value: normalizeImageTransform(input) }
|
||||
} catch (error) {
|
||||
if (error instanceof ImageTransformConfigError) {
|
||||
return { message: error.message, ok: false as const }
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function sendImage(reply: FastifyReply, image: CachedImage, cacheStatus: string, vary: string | null) {
|
||||
if (image.etag) {
|
||||
reply.header("etag", image.etag)
|
||||
}
|
||||
|
||||
if (vary) {
|
||||
reply.header("vary", vary)
|
||||
}
|
||||
|
||||
return reply
|
||||
.code(200)
|
||||
.header("cache-control", image.cacheControl)
|
||||
.header("content-length", image.body.length.toString())
|
||||
.header("content-type", image.contentType)
|
||||
.header("x-image-platform-l1", cacheStatus)
|
||||
.send(image.body)
|
||||
}
|
||||
|
||||
function contentTypeForFormat(format: ActualImageFormat) {
|
||||
if (format === "jpg") {
|
||||
return "image/jpeg"
|
||||
}
|
||||
|
||||
return `image/${format}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user