17 KiB
Image Processing Refactor Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Refactor image processing to use eager generation for admin product images and improve error messages for user uploads.
Architecture: Add eager processing functions to image-resize.js, integrate into upload-images.js via eager flag, update client-side validation and error handling.
Tech Stack: Node.js, Fastify, sharp, React, TypeScript, MUI
Task 1: Add eager processing functions to image-resize.js
Files:
-
Modify:
server/src/lib/image-resize.js -
Test:
server/src/lib/__tests__/image-resize.test.js -
Step 1: Write failing tests for new functions
Add to server/src/lib/__tests__/image-resize.test.js:
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { generateAllSizes, convertOriginalToWebp, findOriginalFile } from '../image-resize.js'
const TEST_UPLOADS_DIR = path.join(process.cwd(), 'uploads-test-eager')
const TEST_CACHE_DIR = path.join(TEST_UPLOADS_DIR, '.cache')
describe('eager image processing', () => {
beforeEach(async () => {
await fs.promises.mkdir(TEST_UPLOADS_DIR, { recursive: true })
})
afterEach(async () => {
await fs.promises.rm(TEST_UPLOADS_DIR, { recursive: true, force: true })
})
it('generateAllSizes creates all width+format combinations', async () => {
// Create a test PNG image using sharp
const sharp = (await import('sharp')).default
const testImagePath = path.join(TEST_UPLOADS_DIR, 'test-uuid.png')
await sharp({ create: { width: 2000, height: 1500, channels: 3, background: { r: 255, g: 0, b: 0 } } })
.png()
.toFile(testImagePath)
await generateAllSizes('test-uuid', '', testImagePath)
// Check all cache files exist
for (const width of [320, 640, 1024, 1600]) {
for (const format of ['avif', 'webp']) {
const cachePath = path.join(TEST_CACHE_DIR, `test-uuid_w${width}.${format}`)
const exists = await fs.promises.access(cachePath).then(() => true).catch(() => false)
expect(exists).toBe(true)
}
}
})
it('convertOriginalToWebp converts and deletes original', async () => {
const sharp = (await import('sharp')).default
const testImagePath = path.join(TEST_UPLOADS_DIR, 'test-uuid2.png')
await sharp({ create: { width: 800, height: 600, channels: 3, background: { r: 0, g: 255, b: 0 } } })
.png()
.toFile(testImagePath)
const result = await convertOriginalToWebp('test-uuid2', '')
expect(result).toBe('/uploads/test-uuid2.webp')
const pngExists = await fs.promises.access(testImagePath).then(() => true).catch(() => false)
expect(pngExists).toBe(false)
const webpPath = path.join(TEST_UPLOADS_DIR, 'test-uuid2.webp')
const webpExists = await fs.promises.access(webpPath).then(() => true).catch(() => false)
expect(webpExists).toBe(true)
})
})
- Step 2: Run tests to verify they fail
Run: cd server && npm test -- --run image-resize.test.js
Expected: FAIL — generateAllSizes and convertOriginalToWebp are not defined
- Step 3: Implement generateAllSizes and convertOriginalToWebp
Add to server/src/lib/image-resize.js before the final export line:
/**
* Generate all resize widths in AVIF + WebP for eager processing.
* @param {string} uuid - UUID without extension
* @param {string} subdir - Subdirectory (e.g., 'reviews') or empty
* @param {string} originalPath - Full path to the original file
*/
export async function generateAllSizes(uuid, subdir, originalPath) {
const cacheSubdir = subdir ? subdir : ''
const cacheDir = path.join(CACHE_DIR, cacheSubdir)
await fs.promises.mkdir(cacheDir, { recursive: true })
const sharp = (await import('sharp')).default
for (const width of VALID_WIDTHS) {
for (const format of SUPPORTED_FORMATS) {
const cacheFileName = `${uuid}_w${width}.${format}`
const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName)
const pipeline = sharp(originalPath).resize(width, null, { withoutEnlargement: true })
const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 }
await pipeline[format](options).toFile(cachePath)
}
}
}
/**
* Convert original file to WebP and delete the source file.
* @param {string} uuid - UUID without extension
* @param {string} subdir - Subdirectory (e.g., 'reviews') or empty
* @returns {string} New URL path like `/uploads/<uuid>.webp`
*/
export async function convertOriginalToWebp(uuid, subdir) {
const uploadsDir = path.join(process.cwd(), 'uploads')
const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir
// Find original file
const originalPath = await findOriginalFile(uuid, subdir)
if (!originalPath) {
throw new Error(`Original file not found for UUID: ${uuid}`)
}
const originalExt = path.extname(originalPath).toLowerCase()
const webpPath = path.join(targetDir, `${uuid}.webp`)
// Convert to WebP
const sharp = (await import('sharp')).default
await sharp(originalPath).webp({ quality: 80 }).toFile(webpPath)
// Delete original if it's not already WebP
if (originalExt !== '.webp') {
await fs.promises.unlink(originalPath)
}
return subdir ? `/uploads/${subdir}/${uuid}.webp` : `/uploads/${uuid}.webp`
}
- Step 4: Run tests to verify they pass
Run: cd server && npm test -- --run image-resize.test.js
Expected: PASS
- Step 5: Commit
git add server/src/lib/image-resize.js server/src/lib/__tests__/image-resize.test.js
git commit -m "feat: add eager image processing functions (generateAllSizes, convertOriginalToWebp)"
Task 2: Integrate eager processing into upload-images.js
Files:
-
Modify:
server/src/lib/upload-images.js -
Test:
server/src/lib/__tests__/upload-images.test.js(create if not exists) -
Step 1: Write failing test for eager mode
Create server/src/lib/__tests__/upload-images.test.js:
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { persistMultipartImages, uploadError } from '../upload-images.js'
const TEST_UPLOADS_DIR = path.join(process.cwd(), 'uploads-test-persist')
describe('persistMultipartImages with eager mode', () => {
beforeEach(async () => {
await fs.promises.mkdir(TEST_UPLOADS_DIR, { recursive: true })
})
afterEach(async () => {
await fs.promises.rm(TEST_UPLOADS_DIR, { recursive: true, force: true })
})
it('returns WebP URLs when eager=true', async () => {
// This test verifies the function signature accepts eager parameter
// Full integration test requires mocking multipart request
// For now, test that the function doesn't throw with eager option
const mockRequest = {
isMultipart: () => true,
parts: async function* () {
// Mock part with a small PNG buffer
const pngHeader = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
...new Array(100).fill(0), // dummy data
])
yield {
file: true,
filename: 'test.png',
toBuffer: async () => pngHeader,
}
},
}
// Should not throw with eager option
try {
await persistMultipartImages(mockRequest, {
maxFiles: 1,
maxFileBytes: 20 * 1024 * 1024,
subdir: '',
eager: true,
})
} catch (err) {
// If sharp is not available or PNG is invalid, that's expected in unit test
// The key is that the function accepts the eager parameter
expect(err.message).not.toContain('eager')
}
})
})
- Step 2: Run test to verify it fails
Run: cd server && npm test -- --run upload-images.test.js
Expected: FAIL — eager parameter is not handled
- Step 3: Modify persistMultipartImages to support eager mode
Replace the persistMultipartImages function in server/src/lib/upload-images.js:
export async function persistMultipartImages(request, { maxFiles = 10, maxFileBytes, subdir = '', eager = false }) {
if (!request.isMultipart()) {
throw uploadError('Ожидается multipart/form-data')
}
const uploadsDir = path.join(process.cwd(), 'uploads')
const targetDir = subdir ? path.join(uploadsDir, subdir) : uploadsDir
await fs.promises.mkdir(targetDir, { recursive: true })
const urls = []
const parts = request.parts({
limits: {
fileSize: maxFileBytes,
files: maxFiles,
},
})
for await (const part of parts) {
if (!part.file) continue
if (urls.length >= maxFiles) {
throw uploadError(`Можно загрузить не более ${maxFiles} файл(ов)`)
}
const ext = safeImageExt(part.filename)
if (!ext) {
throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp')
}
const uuid = crypto.randomUUID()
const fileName = `${uuid}${ext}`
const fullPath = path.join(targetDir, fileName)
await fs.promises.writeFile(fullPath, await part.toBuffer())
let finalUrl = subdir ? `/uploads/${subdir}/${fileName}` : `/uploads/${fileName}`
if (eager) {
const { generateAllSizes, convertOriginalToWebp } = await import('./image-resize.js')
await generateAllSizes(uuid, subdir, fullPath)
finalUrl = await convertOriginalToWebp(uuid, subdir)
}
urls.push(finalUrl)
}
if (urls.length === 0) {
throw uploadError(
'Файлы не получены. Проверьте, что запрос multipart/form-data и поля — файлы изображений (png, jpg, webp).',
)
}
return urls
}
- Step 4: Run test to verify it passes
Run: cd server && npm test -- --run upload-images.test.js
Expected: PASS
- Step 5: Commit
git add server/src/lib/upload-images.js server/src/lib/__tests__/upload-images.test.js
git commit -m "feat: add eager mode to persistMultipartImages"
Task 3: Enable eager mode in admin upload route
Files:
-
Modify:
server/src/routes/api/admin-products.js -
Step 1: Update admin upload route to use eager mode
Modify the POST /api/admin/uploads route in server/src/routes/api/admin-products.js:
fastify.post(
'/api/admin/uploads',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
try {
const urls = await persistMultipartImages(request, {
maxFiles: 10,
maxFileBytes: getProductImageMaxFileBytes(),
eager: true,
})
await upsertGalleryImagesByUrls(urls)
return { urls }
} catch (error) {
let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'
let statusCode =
error && typeof error === 'object' && 'statusCode' in error && Number.isInteger(error.statusCode)
? Number(error.statusCode)
: 400
if (isMultipartFileTooLargeError(error)) {
message = formatFileTooLargeMessage(getProductImageMaxFileBytes())
statusCode = 413
}
return reply.code(statusCode).send({ error: message })
}
},
)
- Step 2: Commit
git add server/src/routes/api/admin-products.js
git commit -m "feat: enable eager image processing for admin uploads"
Task 4: Improve user upload error messages
Files:
-
Modify:
client/src/entities/product/api/reviews-api.ts -
Modify:
client/src/shared/constants/upload-limits.ts -
Modify:
client/src/features/product-review/ui/ReviewDialog.tsx -
Step 1: Add client-side size validation for review images
Add to client/src/shared/constants/upload-limits.ts:
export const OTHER_UPLOAD_MAX_FILE_BYTES = 2 * 1024 * 1024 // 2 MB
export function formatOtherUploadMaxSizeHint(): string {
return `${Math.round(OTHER_UPLOAD_MAX_FILE_BYTES / (1024 * 1024))} МБ`
}
- Step 2: Add pre-upload size check in reviews-api.ts
Modify uploadReviewImage in client/src/entities/product/api/reviews-api.ts:
import { OTHER_UPLOAD_MAX_FILE_BYTES, formatOtherUploadMaxSizeHint } from '@/shared/constants/upload-limits'
export async function uploadReviewImage(file: File): Promise<{ url: string }> {
if (file.size > OTHER_UPLOAD_MAX_FILE_BYTES) {
throw new Error(
`Файл «${file.name}» слишком большой (максимум ${formatOtherUploadMaxSizeHint()}).`,
)
}
const fd = new FormData()
fd.append('file', file, file.name)
const { data } = await apiClient.post<{ url: string }>('reviews/upload-image', fd)
return data
}
- Step 3: Update ReviewDialog to show user-friendly error message
Modify the uploadError display in client/src/features/product-review/ui/ReviewDialog.tsx:
Replace:
{uploadError ? (
<Alert severity="error" sx={{ mt: 2 }}>
Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.
</Alert>
) : null}
With:
{uploadError ? (
<Alert severity="error" sx={{ mt: 2 }}>
{uploadError instanceof Error ? uploadError.message : 'Не удалось загрузить фото. Разрешены png, jpg, jpeg, webp.'}
</Alert>
) : null}
- Step 4: Commit
git add client/src/shared/constants/upload-limits.ts client/src/entities/product/api/reviews-api.ts client/src/features/product-review/ui/ReviewDialog.tsx
git commit -m "feat: improve error messages for user upload size validation"
Task 5: Update OptimizedImage for WebP originals
Files:
-
Modify:
client/src/shared/ui/OptimizedImage.tsx -
Test:
client/src/shared/ui/__tests__/OptimizedImage.test.tsx -
Step 1: Update parseUploadUrl to handle .webp originals
Modify parseUploadUrl in client/src/shared/ui/OptimizedImage.tsx:
function parseUploadUrl(src: string): { uuid: string; ext: string; subdir: string } | null {
const match = src.match(/^\/uploads(?:\/(reviews))?\/([^.\\/]+)\.(png|jpe?g|webp)/i)
if (!match) return null
return { subdir: match[1] || '', uuid: match[2], ext: match[3].toLowerCase() }
}
- Step 2: Update buildSrcSet to use cached AVIF/WebP directly
Modify buildSrcSet and buildFallbackSrc:
function buildSrcSet(src: string, widths: number[]): string | null {
const parsed = parseUploadUrl(src)
if (!parsed) return null
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
return widths.map((w) => `/uploads-resized/${pathPrefix}${parsed.uuid}.avif?w=${w} ${w}w`).join(', ')
}
function buildFallbackSrc(src: string, width: number): string {
const parsed = parseUploadUrl(src)
if (!parsed) return src
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
return `/uploads-resized/${pathPrefix}${parsed.uuid}.webp?w=${width}`
}
- Step 3: Add original WebP URL getter for full-screen mode
Add to client/src/shared/ui/OptimizedImage.tsx:
/** Get the original WebP URL for full-screen display (no resize) */
export function getOriginalWebpUrl(src: string): string {
const parsed = parseUploadUrl(src)
if (!parsed) return src
const pathPrefix = parsed.subdir ? `${parsed.subdir}/` : ''
return `/uploads/${pathPrefix}${parsed.uuid}.webp`
}
- Step 4: Commit
git add client/src/shared/ui/OptimizedImage.tsx
git commit -m "feat: update OptimizedImage for WebP originals and add getOriginalWebpUrl"
Task 6: Update ProductPage full-screen viewer
Files:
-
Modify:
client/src/pages/product/ui/ProductPage.tsx -
Step 1: Find full-screen image viewer code
Search for the full-screen image viewer in ProductPage.tsx. Look for where the original image URL is used.
- Step 2: Use getOriginalWebpUrl for full-screen display
Import and use getOriginalWebpUrl:
import { getOriginalWebpUrl } from '@/shared/ui/OptimizedImage'
Replace the full-screen <img> src with:
getOriginalWebpUrl(imageUrl)
- Step 3: Commit
git add client/src/pages/product/ui/ProductPage.tsx
git commit -m "feat: use WebP original for full-screen product image viewer"
Task 7: Run full test suite and lint
- Step 1: Run server tests
cd server && npm test
- Step 2: Run client lint and format check
cd client && npm run lint && npm run format:check
- Step 3: Run client tests
cd client && npm test
- Step 4: Run client build
cd client && npm run build
- Step 5: Commit any fixes
git add .
git commit -m "fix: address lint and test issues"