@chrono-os/image-editor-backend
v0.2.0
Published
Upload + Sharp optimizer + Fastify plugin para o editor de imagens com crop visual
Readme
@chrono-os/image-editor-backend
Pipeline Sharp (WebP qualidade 82, resize max 1920px, max input 15MB) + plugin Fastify pra upload/list/delete em ${prefix}. Pareado com @chrono-os/image-editor-react — o adapter do frontend bate nas rotas que esse plugin registra.
Install
yarn add @chrono-os/image-editor-backend
yarn add fastify @fastify/multipart sharpPeer dependencies: fastify@^4 || ^5 sharp@^0.33.
@fastify/multipart é dependência do consumer (não peer formal pra não amarrar versão major), mas obrigatório pra uploadPlugin funcionar.
Exemplo: Fastify completo
// app.ts
import Fastify from 'fastify'
import multipart from '@fastify/multipart'
import { uploadPlugin } from '@chrono-os/image-editor-backend'
const app = Fastify({ logger: true })
// 1) Multipart parser PRIMEIRO (registrado no escopo root).
await app.register(multipart, {
limits: { fileSize: 15 * 1024 * 1024, files: 1 },
})
// 2) Depois o uploadPlugin com prefix.
await app.register(uploadPlugin, {
prefix: '/admin/uploads',
uploadsDir: process.env.UPLOADS_DIR ?? '/var/uploads',
publicUrl: process.env.UPLOADS_PUBLIC_URL, // ex 'https://cdn.example.com/uploads'
})
await app.listen({ port: 3000, host: '0.0.0.0' })Rotas registradas (relativas a prefix):
GET /admin/uploads→{ items: UploadListItem[] }POST /admin/uploads→201 UploadedImage(multipart com fieldfile)DELETE /admin/uploads/:filename→{ ok: true }ou404
Gotcha:
@fastify/multipartDEVE ser registrado ANTES douploadPlugin. Sem isso,request.file()não existe e o plugin responde500 MULTIPART_NOT_REGISTERED.
Exemplo: com authHook
Integração típica com cookie+JWT do consumer:
import { uploadPlugin } from '@chrono-os/image-editor-backend'
await app.register(uploadPlugin, {
prefix: '/admin/uploads',
uploadsDir: env.UPLOADS_DIR,
publicUrl: env.UPLOADS_PUBLIC_URL,
authHook: async (request, reply) => {
// request.adminUser foi setado num onRequest hook anterior
// (decorando request com jose.verifyJwt do cookie).
if (!request.adminUser) {
return false // plugin responde 401 automaticamente
}
return true
},
})O hook recebe (request, reply) e retorna boolean (ou Promise<boolean>). Retornar false faz o plugin enviar 401 { error: 'UNAUTHORIZED' } automaticamente — não precisa chamar reply.send() manualmente.
Exemplo: createImageOptimizer standalone
Pra quem quer só o pipeline Sharp (sem Fastify — ex CLI, NestJS, serverless lambda):
import { createImageOptimizer } from '@chrono-os/image-editor-backend'
import { readFile } from 'node:fs/promises'
const optimizer = createImageOptimizer({
uploadsDir: '/var/uploads',
publicUrl: 'https://cdn.example.com/uploads',
maxInputBytes: 20 * 1024 * 1024, // override pra 20MB
maxOutputWidth: 2400,
webpQuality: 88,
})
await optimizer.ensureUploadsDir()
const buffer = await readFile('/tmp/source.jpg')
const saved = await optimizer.saveAndOptimizeImage({
data: buffer,
mimeType: 'image/jpeg',
originalFilename: 'foto-naírio.jpg', // será saneado pra 'foto-nairio.webp'
})
console.log(saved)
// { filename: 'foto-nairio.webp', url: 'https://cdn.example.com/uploads/foto-nairio.webp',
// width: 1600, height: 2133, sizeBytes: 184_293, mimeType: 'image/webp' }
const items = await optimizer.listUploads()
await optimizer.deleteUpload('foto-nairio.webp')Config (ImageOptimizerConfig)
| Campo | Tipo | Default | Descrição |
|---|---|---|---|
| uploadsDir | string | — (obrigatório) | Path absoluto onde os WebPs são gravados. |
| publicUrl | string | — | URL base servindo uploadsDir. Quando ausente, retorna /uploads/<filename> relativo. |
| maxInputBytes | number | 15 * 1024 * 1024 | Tamanho máximo do upload em bytes. |
| maxOutputWidth | number | 1920 | Largura máxima do WebP gerado (px). Aspect preservado via Sharp fit:'inside'. |
| webpQuality | number | 82 | Qualidade WebP (0-100). |
| allowedMime | Set<string> | {'image/jpeg','image/png','image/webp','image/avif','image/gif'} | MIME types aceitos na entrada. |
Plugin options (UploadPluginOptions)
Estende ImageOptimizerConfig com:
| Campo | Tipo | Default | Descrição |
|---|---|---|---|
| authHook | (req, reply) => boolean \| Promise<boolean> | — | Autorização por rota. false → 401 automático. |
| maxFiles | number | 1 | Limite de arquivos por request multipart. |
Pra montar as rotas em path custom, use prefix do próprio Fastify register:
await app.register(uploadPlugin, { prefix: '/api/uploads', uploadsDir, publicUrl })Comportamento de filename
saveAndOptimizeImage resolve o nome final assim:
- Pega o
originalFilename(ou usa fallback randomlp-<ts36>-<hex>.webpse ausente). - Sanitiza: lowercase, NFD strip acentos, substitui chars não-alfanuméricos por
-, colapsa-repetidos, trim de hífens, slice 60 chars. - Força extensão
.webp(Sharp sempre transcodifica). - Se já existe arquivo com mesmo nome em
uploadsDir, tenta-2,-3, ...,-100. Acima de 100 colisões cai no fallback random.
Exemplos:
"Foto do Naírio.JPG"→"foto-do-nairio.webp"(ou"foto-do-nairio-2.webp"se houver colisão)"_____ .png"(stem vazio após sanitização) → fallback randomlp-XXX.webpnome >60 chars→ truncado pra 60
License
MIT
