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

@@ -1,8 +1,14 @@
import { defineConfig } from "drizzle-kit"
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
throw new Error("DATABASE_URL is required")
}
export default defineConfig({
dbCredentials: {
url: process.env.DATABASE_URL ?? "postgres://image:image-password@localhost:5433/image_platform",
url: databaseUrl,
},
dialect: "postgresql",
out: "./drizzle",

View File

@@ -0,0 +1,4 @@
CREATE TYPE "public"."resize_mode" AS ENUM('fit', 'fill');--> statement-breakpoint
DROP INDEX "image_variants_lookup_idx";--> statement-breakpoint
ALTER TABLE "image_variants" ADD COLUMN "resize_mode" "resize_mode" DEFAULT 'fit' NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "image_variants_lookup_idx" ON "image_variants" USING btree ("asset_id","asset_version","preset","width","height","resize_mode","quality","format");

View File

@@ -0,0 +1,632 @@
{
"id": "9b706710-b809-4324-8632-634884f75166",
"prevId": "72292622-d326-46fe-8e6a-90096c7e6634",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.allowed_image_hosts": {
"name": "allowed_image_hosts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"hostname": {
"name": "hostname",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"allowed_image_hosts_hostname_idx": {
"name": "allowed_image_hosts_hostname_idx",
"columns": [
{
"expression": "hostname",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.image_asset_versions": {
"name": "image_asset_versions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"asset_id": {
"name": "asset_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_host": {
"name": "source_host",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_hash": {
"name": "source_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"original_s3_key": {
"name": "original_s3_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"width": {
"name": "width",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"height": {
"name": "height",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"size_bytes": {
"name": "size_bytes",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"image_asset_versions_asset_version_idx": {
"name": "image_asset_versions_asset_version_idx",
"columns": [
{
"expression": "asset_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "version",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"image_asset_versions_source_hash_idx": {
"name": "image_asset_versions_source_hash_idx",
"columns": [
{
"expression": "source_hash",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"image_asset_versions_asset_id_image_assets_id_fk": {
"name": "image_asset_versions_asset_id_image_assets_id_fk",
"tableFrom": "image_asset_versions",
"tableTo": "image_assets",
"columnsFrom": [
"asset_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.image_assets": {
"name": "image_assets",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"public_id": {
"name": "public_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"current_version": {
"name": "current_version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"status": {
"name": "status",
"type": "asset_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'active'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"image_assets_public_id_idx": {
"name": "image_assets_public_id_idx",
"columns": [
{
"expression": "public_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.image_variants": {
"name": "image_variants",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"asset_id": {
"name": "asset_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"asset_version_id": {
"name": "asset_version_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"asset_version": {
"name": "asset_version",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"preset": {
"name": "preset",
"type": "text",
"primaryKey": false,
"notNull": true
},
"variant_hash": {
"name": "variant_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"requested_format": {
"name": "requested_format",
"type": "requested_format",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'auto'"
},
"format": {
"name": "format",
"type": "variant_format",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"width": {
"name": "width",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"height": {
"name": "height",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"resize_mode": {
"name": "resize_mode",
"type": "resize_mode",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'fit'"
},
"quality": {
"name": "quality",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"s3_key": {
"name": "s3_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"content_type": {
"name": "content_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"etag": {
"name": "etag",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "variant_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"size_bytes": {
"name": "size_bytes",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"attempt_count": {
"name": "attempt_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"last_accessed_at": {
"name": "last_accessed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"image_variants_lookup_idx": {
"name": "image_variants_lookup_idx",
"columns": [
{
"expression": "asset_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "asset_version",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "preset",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "width",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "height",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "resize_mode",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "quality",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "format",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"image_variants_s3_key_idx": {
"name": "image_variants_s3_key_idx",
"columns": [
{
"expression": "s3_key",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"image_variants_variant_hash_idx": {
"name": "image_variants_variant_hash_idx",
"columns": [
{
"expression": "variant_hash",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"image_variants_status_idx": {
"name": "image_variants_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"image_variants_asset_id_image_assets_id_fk": {
"name": "image_variants_asset_id_image_assets_id_fk",
"tableFrom": "image_variants",
"tableTo": "image_assets",
"columnsFrom": [
"asset_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"image_variants_asset_version_id_image_asset_versions_id_fk": {
"name": "image_variants_asset_version_id_image_asset_versions_id_fk",
"tableFrom": "image_variants",
"tableTo": "image_asset_versions",
"columnsFrom": [
"asset_version_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.asset_status": {
"name": "asset_status",
"schema": "public",
"values": [
"active",
"disabled",
"deleted"
]
},
"public.requested_format": {
"name": "requested_format",
"schema": "public",
"values": [
"auto",
"avif",
"webp",
"jpg",
"png"
]
},
"public.resize_mode": {
"name": "resize_mode",
"schema": "public",
"values": [
"fit",
"fill"
]
},
"public.variant_format": {
"name": "variant_format",
"schema": "public",
"values": [
"avif",
"webp",
"jpg",
"png"
]
},
"public.variant_status": {
"name": "variant_status",
"schema": "public",
"values": [
"pending",
"processing",
"ready",
"failed"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1777963363578,
"tag": "0000_calm_magdalene",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1777973330318,
"tag": "0001_familiar_nextwave",
"breakpoints": true
}
]
}

View File

@@ -2,19 +2,20 @@
"name": "@image-platform/database",
"version": "0.1.0",
"private": true,
"type": "module",
"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",
"db:generate": "drizzle-kit generate --config drizzle.config.ts",
"db:migrate": "drizzle-kit migrate --config drizzle.config.ts",
"db:studio": "drizzle-kit studio --config drizzle.config.ts",
"db:generate": "node --env-file-if-exists=../../.env ./node_modules/drizzle-kit/bin.cjs generate --config drizzle.config.ts",
"db:migrate": "node --env-file-if-exists=../../.env ./node_modules/drizzle-kit/bin.cjs migrate --config drizzle.config.ts",
"db:studio": "node --env-file-if-exists=../../.env ./node_modules/drizzle-kit/bin.cjs studio --config drizzle.config.ts",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {

View File

@@ -1,2 +1,3 @@
export { createDatabase, createDatabasePool } from "./client.js"
export type { Database, DatabasePool } from "./client.js"
export * from "./schema.js"

View File

@@ -14,6 +14,7 @@ import {
export const assetStatusEnum = pgEnum("asset_status", ["active", "disabled", "deleted"])
export const variantFormatEnum = pgEnum("variant_format", ["avif", "webp", "jpg", "png"])
export const requestedFormatEnum = pgEnum("requested_format", ["auto", "avif", "webp", "jpg", "png"])
export const resizeModeEnum = pgEnum("resize_mode", ["fit", "fill"])
export const variantStatusEnum = pgEnum("variant_status", ["pending", "processing", "ready", "failed"])
const timestamps = {
@@ -86,6 +87,7 @@ export const imageVariants = pgTable(
format: variantFormatEnum("format").notNull(),
width: integer("width").notNull(),
height: integer("height"),
resizeMode: resizeModeEnum("resize_mode").notNull().default("fit"),
quality: integer("quality").notNull(),
s3Key: text("s3_key").notNull(),
contentType: text("content_type"),
@@ -103,6 +105,8 @@ export const imageVariants = pgTable(
table.assetVersion,
table.preset,
table.width,
table.height,
table.resizeMode,
table.quality,
table.format,
),

View File

@@ -5,8 +5,8 @@
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"module": "Node16",
"moduleResolution": "Node16",
"noUncheckedIndexedAccess": true,
"outDir": "./dist",
"rootDir": "./src",

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"]
}

View File

@@ -2,10 +2,11 @@
"name": "@image-platform/queue",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"require": "./dist/index.js",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
@@ -15,7 +16,11 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"devDependencies": {
"@types/amqplib": "^0.10.8",
"@types/node": "^25.6.0",
"typescript": "^6.0.3"
},
"dependencies": {
"amqplib": "^1.0.4"
}
}

View File

@@ -0,0 +1,29 @@
import type { Channel } from "amqplib"
import type { GenerateVariantJob } from "./jobs.js"
import type { QueueTopology } from "./topology.js"
export async function assertQueueTopology(channel: Channel, topology: QueueTopology) {
await channel.assertExchange(topology.jobsExchange, "direct", { durable: true })
await channel.assertExchange(topology.jobsDeadLetterExchange, "direct", { durable: true })
await channel.assertQueue(topology.generateVariantQueue, {
deadLetterExchange: topology.jobsDeadLetterExchange,
deadLetterRoutingKey: topology.generateVariantDeadLetterRoutingKey,
durable: true,
})
await channel.assertQueue(topology.generateVariantDeadLetterQueue, { durable: true })
await channel.bindQueue(topology.generateVariantQueue, topology.jobsExchange, topology.generateVariantRoutingKey)
await channel.bindQueue(
topology.generateVariantDeadLetterQueue,
topology.jobsDeadLetterExchange,
topology.generateVariantDeadLetterRoutingKey,
)
}
export function publishGenerateVariantJob(channel: Channel, topology: QueueTopology, job: GenerateVariantJob) {
return channel.publish(topology.jobsExchange, topology.generateVariantRoutingKey, Buffer.from(JSON.stringify(job)), {
contentType: "application/json",
deliveryMode: 2,
persistent: true,
})
}

View File

@@ -1,2 +1,3 @@
export * from "./amqp.js"
export * from "./jobs.js"
export * from "./topology.js"

View File

@@ -5,8 +5,8 @@
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"module": "Node16",
"moduleResolution": "Node16",
"noUncheckedIndexedAccess": true,
"outDir": "./dist",
"rootDir": "./src",

View File

@@ -2,10 +2,11 @@
"name": "@image-platform/storage",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"require": "./dist/index.js",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},

View File

@@ -8,11 +8,15 @@ export type StorageConfig = {
}
export function loadStorageConfigFromEnv(env: NodeJS.ProcessEnv = process.env): StorageConfig {
if (!env.S3_BUCKET) {
throw new Error("S3_BUCKET is required")
}
return {
accessKeyId: normalizeOptionalString(env.S3_ACCESS_KEY_ID),
bucket: env.S3_BUCKET ?? "image-platform",
bucket: env.S3_BUCKET,
endpoint: normalizeOptionalString(env.S3_ENDPOINT),
forcePathStyle: parseBoolean(env.S3_FORCE_PATH_STYLE, true),
forcePathStyle: parseBoolean(env.S3_FORCE_PATH_STYLE, false),
region: env.S3_REGION ?? "us-east-1",
secretAccessKey: normalizeOptionalString(env.S3_SECRET_ACCESS_KEY),
}

View File

@@ -1,3 +1,4 @@
export * from "./client.js"
export * from "./config.js"
export * from "./keys.js"
export * from "./objects.js"

View File

@@ -0,0 +1,91 @@
import { GetObjectCommand, HeadObjectCommand, PutObjectCommand, type S3Client } from "@aws-sdk/client-s3"
export type StoredObject = {
body: Buffer
contentLength: number | null
contentType: string | null
etag: string | null
}
export async function getObjectBuffer(client: S3Client, bucket: string, key: string): Promise<StoredObject | null> {
try {
const response = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
if (!response.Body) {
throw new Error(`S3 object ${key} has no body`)
}
const body = await streamToBuffer(response.Body as AsyncIterable<Uint8Array>)
return {
body,
contentLength: response.ContentLength ?? body.length,
contentType: response.ContentType ?? null,
etag: response.ETag ?? null,
}
} catch (error) {
if (isS3NotFound(error)) {
return null
}
throw error
}
}
export async function objectExists(client: S3Client, bucket: string, key: string) {
try {
await client.send(new HeadObjectCommand({ Bucket: bucket, Key: key }))
return true
} catch (error) {
if (isS3NotFound(error)) {
return false
}
throw error
}
}
export async function putObjectBuffer(input: {
body: Buffer
bucket: string
cacheControl?: string
client: S3Client
contentType: string
key: string
}) {
return input.client.send(
new PutObjectCommand({
Body: input.body,
Bucket: input.bucket,
CacheControl: input.cacheControl,
ContentType: input.contentType,
Key: input.key,
}),
)
}
function isS3NotFound(error: unknown) {
if (!(error instanceof Error)) {
return false
}
const withMetadata = error as Error & { $metadata?: { httpStatusCode?: number }; Code?: string; code?: string }
return (
withMetadata.$metadata?.httpStatusCode === 404 ||
error.name === "NoSuchKey" ||
error.name === "NotFound" ||
withMetadata.Code === "NoSuchKey" ||
withMetadata.code === "NoSuchKey"
)
}
async function streamToBuffer(stream: AsyncIterable<Uint8Array>) {
const chunks: Buffer[] = []
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk))
}
return Buffer.concat(chunks)
}

View File

@@ -5,8 +5,8 @@
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"module": "Node16",
"moduleResolution": "Node16",
"noUncheckedIndexedAccess": true,
"outDir": "./dist",
"rootDir": "./src",