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:
22
packages/image-config/package.json
Normal file
22
packages/image-config/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
20
packages/image-config/src/allowed-hosts.ts
Normal file
20
packages/image-config/src/allowed-hosts.ts
Normal 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())
|
||||
}
|
||||
2
packages/image-config/src/index.ts
Normal file
2
packages/image-config/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./allowed-hosts.js"
|
||||
export * from "./presets.js"
|
||||
297
packages/image-config/src/presets.ts
Normal file
297
packages/image-config/src/presets.ts
Normal 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)
|
||||
}
|
||||
7
packages/image-config/tsconfig.build.json
Normal file
7
packages/image-config/tsconfig.build.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false
|
||||
},
|
||||
"exclude": ["dist", "node_modules", "**/*.spec.ts"]
|
||||
}
|
||||
21
packages/image-config/tsconfig.json
Normal file
21
packages/image-config/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user