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:
2026-05-05 13:25:28 +03:00
parent bcadb85a83
commit 1c0e8277a3
59 changed files with 3526 additions and 143 deletions

View File

@@ -0,0 +1,22 @@
{
"name": "@image-platform/image-config",
"version": "0.1.0",
"private": true,
"exports": {
".": {
"types": "./src/index.ts",
"require": "./dist/index.js",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"types": "./src/index.ts",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"devDependencies": {
"@types/node": "^25.6.0",
"typescript": "^6.0.3"
}
}

View File

@@ -0,0 +1,20 @@
export const DEFAULT_ALLOWED_SOURCE_HOSTS = ["storage.yandexcloud.net"] as const
export function loadAllowedSourceHostsFromEnv(env: NodeJS.ProcessEnv = process.env) {
const value = env.SOURCE_ALLOWED_HOSTS
if (!value) {
return new Set<string>(DEFAULT_ALLOWED_SOURCE_HOSTS)
}
return new Set(
value
.split(",")
.map((host) => host.trim().toLowerCase())
.filter(Boolean),
)
}
export function isAllowedSourceHost(hostname: string, allowedHosts: ReadonlySet<string>) {
return allowedHosts.has(hostname.toLowerCase())
}

View File

@@ -0,0 +1,2 @@
export * from "./allowed-hosts.js"
export * from "./presets.js"

View File

@@ -0,0 +1,297 @@
export type ActualImageFormat = "avif" | "jpg" | "png" | "webp"
export type RequestedImageFormat = "auto" | ActualImageFormat
export type ResizeMode = "fill" | "fit"
export type PresetMode = "fixed" | "responsive"
export type TransformMode = "custom" | PresetMode
export type ImagePreset = {
formats: readonly ActualImageFormat[]
height?: number
mode: PresetMode
qualities: readonly number[]
quality: number
resize: ResizeMode
width?: number
widths?: readonly number[]
}
export type CustomTransformConfig = {
formats: readonly ActualImageFormat[]
maxHeight: number
maxWidth: number
quality: number
}
export type NormalizeImageTransformInput = {
allowCustomTransforms: boolean
format: ActualImageFormat
height?: number | null
preset: string
quality?: number | null
requestedFormat?: RequestedImageFormat | null
resize?: ResizeMode | null
width?: number | null
}
export type NormalizedImageTransform = {
format: ActualImageFormat
height: number
mode: TransformMode
preset: string
quality: number
requestedFormat: RequestedImageFormat
resize: ResizeMode
width: number
}
export class ImageTransformConfigError extends Error {
constructor(message: string) {
super(message)
this.name = "ImageTransformConfigError"
}
}
export const IMAGE_PRESETS = {
avatar: {
formats: ["avif", "webp", "jpg"],
height: 256,
mode: "fixed",
qualities: [80],
quality: 80,
resize: "fill",
width: 256,
},
card: {
formats: ["avif", "webp", "jpg"],
mode: "responsive",
qualities: [75, 80],
quality: 80,
resize: "fit",
widths: [320, 640, 960],
},
hero: {
formats: ["avif", "webp", "jpg"],
mode: "responsive",
qualities: [75, 80],
quality: 80,
resize: "fit",
widths: [1280, 1920],
},
} as const satisfies Record<string, ImagePreset>
export const CUSTOM_PRESET_NAME = "custom"
export const CUSTOM_TRANSFORM_CONFIG: CustomTransformConfig = {
formats: ["avif", "webp", "jpg", "png"],
maxHeight: 4096,
maxWidth: 4096,
quality: 80,
}
export function getImagePreset(name: string): ImagePreset | null {
return Object.hasOwn(IMAGE_PRESETS, name) ? IMAGE_PRESETS[name as keyof typeof IMAGE_PRESETS] : null
}
export function normalizeImageTransform(input: NormalizeImageTransformInput): NormalizedImageTransform {
const requestedFormat = input.requestedFormat ?? input.format
const preset = getImagePreset(input.preset)
if (!isRequestedImageFormat(requestedFormat)) {
throw new ImageTransformConfigError("requestedFormat is invalid")
}
if (!isActualImageFormat(input.format)) {
throw new ImageTransformConfigError("format is invalid")
}
if (preset) {
return normalizePresetTransform(input, preset, requestedFormat)
}
if (input.preset === CUSTOM_PRESET_NAME) {
return normalizeCustomTransform(input, requestedFormat)
}
throw new ImageTransformConfigError(`unknown image preset: ${input.preset}`)
}
export function selectFormatForAccept(input: {
allowCustomTransforms: boolean
acceptHeader?: string | string[]
preset: string
requestedFormat: string
}): { format: ActualImageFormat; requestedFormat: RequestedImageFormat } {
if (!isRequestedImageFormat(input.requestedFormat)) {
throw new ImageTransformConfigError("requested format is invalid")
}
const formats = getAllowedFormats(input.preset, input.allowCustomTransforms)
if (input.requestedFormat !== "auto") {
if (!formats.includes(input.requestedFormat)) {
throw new ImageTransformConfigError(`format ${input.requestedFormat} is not allowed for ${input.preset}`)
}
return { format: input.requestedFormat, requestedFormat: input.requestedFormat }
}
const accept = Array.isArray(input.acceptHeader) ? input.acceptHeader.join(",") : (input.acceptHeader ?? "")
if (accept.includes("image/avif") && formats.includes("avif")) {
return { format: "avif", requestedFormat: "auto" }
}
if (accept.includes("image/webp") && formats.includes("webp")) {
return { format: "webp", requestedFormat: "auto" }
}
if (formats.includes("jpg")) {
return { format: "jpg", requestedFormat: "auto" }
}
if (formats.includes("png")) {
return { format: "png", requestedFormat: "auto" }
}
throw new ImageTransformConfigError(`no fallback format configured for ${input.preset}`)
}
export function parseBooleanFlag(value: string | undefined, fallback: boolean) {
if (value === undefined) {
return fallback
}
return ["1", "true", "yes"].includes(value.toLowerCase())
}
function normalizePresetTransform(
input: NormalizeImageTransformInput,
preset: ImagePreset,
requestedFormat: RequestedImageFormat,
): NormalizedImageTransform {
if (!preset.formats.includes(input.format)) {
throw new ImageTransformConfigError(`format ${input.format} is not allowed for preset ${input.preset}`)
}
const quality = normalizePresetQuality(input.quality, preset)
if (preset.mode === "fixed") {
if (!preset.width || !preset.height) {
throw new ImageTransformConfigError(`fixed preset ${input.preset} must define width and height`)
}
if (input.width !== null && input.width !== undefined && input.width !== preset.width) {
throw new ImageTransformConfigError(`width must be ${preset.width} for preset ${input.preset}`)
}
if (input.height !== null && input.height !== undefined && input.height !== preset.height) {
throw new ImageTransformConfigError(`height must be ${preset.height} for preset ${input.preset}`)
}
return {
format: input.format,
height: preset.height,
mode: preset.mode,
preset: input.preset,
quality,
requestedFormat,
resize: preset.resize,
width: preset.width,
}
}
if (!input.width) {
throw new ImageTransformConfigError(`width is required for responsive preset ${input.preset}`)
}
if (!preset.widths?.includes(input.width)) {
throw new ImageTransformConfigError(`width ${input.width} is not allowed for preset ${input.preset}`)
}
if (input.height !== null && input.height !== undefined && input.height !== (preset.height ?? 0)) {
throw new ImageTransformConfigError(`height is not configurable for preset ${input.preset}`)
}
return {
format: input.format,
height: preset.height ?? 0,
mode: preset.mode,
preset: input.preset,
quality,
requestedFormat,
resize: preset.resize,
width: input.width,
}
}
function normalizeCustomTransform(
input: NormalizeImageTransformInput,
requestedFormat: RequestedImageFormat,
): NormalizedImageTransform {
if (!input.allowCustomTransforms) {
throw new ImageTransformConfigError("custom transforms are disabled")
}
if (!CUSTOM_TRANSFORM_CONFIG.formats.includes(input.format)) {
throw new ImageTransformConfigError(`format ${input.format} is not allowed for custom transforms`)
}
if (!input.width || input.width > CUSTOM_TRANSFORM_CONFIG.maxWidth) {
throw new ImageTransformConfigError(`custom width must be between 1 and ${CUSTOM_TRANSFORM_CONFIG.maxWidth}`)
}
const height = input.height ?? 0
if (height < 0 || height > CUSTOM_TRANSFORM_CONFIG.maxHeight) {
throw new ImageTransformConfigError(`custom height must be between 0 and ${CUSTOM_TRANSFORM_CONFIG.maxHeight}`)
}
const quality = input.quality ?? CUSTOM_TRANSFORM_CONFIG.quality
if (!Number.isSafeInteger(quality) || quality < 1 || quality > 100) {
throw new ImageTransformConfigError("custom quality must be between 1 and 100")
}
return {
format: input.format,
height,
mode: "custom",
preset: input.preset,
quality,
requestedFormat,
resize: input.resize ?? "fit",
width: input.width,
}
}
function normalizePresetQuality(value: number | null | undefined, preset: ImagePreset) {
const quality = value ?? preset.quality
if (!preset.qualities.includes(quality)) {
throw new ImageTransformConfigError(`quality ${quality} is not allowed for preset`)
}
return quality
}
function getAllowedFormats(presetName: string, allowCustomTransforms: boolean) {
const preset = getImagePreset(presetName)
if (preset) {
return preset.formats
}
if (presetName === CUSTOM_PRESET_NAME && allowCustomTransforms) {
return CUSTOM_TRANSFORM_CONFIG.formats
}
throw new ImageTransformConfigError(`unknown image preset: ${presetName}`)
}
function isActualImageFormat(value: string): value is ActualImageFormat {
return value === "avif" || value === "webp" || value === "jpg" || value === "png"
}
function isRequestedImageFormat(value: string): value is RequestedImageFormat {
return value === "auto" || isActualImageFormat(value)
}

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false
},
"exclude": ["dist", "node_modules", "**/*.spec.ts"]
}

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"declaration": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ES2023"],
"module": "Node16",
"moduleResolution": "Node16",
"noUncheckedIndexedAccess": true,
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "ES2023",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}