@xenterprises/fastify-ximagepipeline
v1.0.0
Published
Fastify plugin for image uploads with EXIF stripping, moderation, variant generation, and R2 storage with job queue
Readme
xMedia Plugin for Fastify v5
Fastify plugin for handling image uploads with automatic EXIF stripping, content moderation, WebP variant generation, and Cloudflare R2 storage using a job queue pattern.
Features
✨ Core Capabilities
- 🖼️ Image Upload - Multipart file upload with validation
- 🔍 EXIF Stripping - Remove metadata while preserving orientation
- 🎨 Variant Generation - Automatic WebP variants at multiple sizes
- 📦 R2 Storage - Direct integration with Cloudflare R2 (S3-compatible)
- ⏳ Job Queue - Database-backed processing queue with retry logic
- 🔒 Content Moderation - Pluggable moderation API support
- 🎯 Focal Points - Smart cropping hints for UI
- 📊 Blurhash - Loading placeholders for instant UI feedback
- 🧹 Cleanup - Automatic staging cleanup and stale job recovery
Installation
npm install @xenterprises/fastify-xmedia @fastify/multipartSetup
1. Add Prisma Models
Add these models to your schema.prisma:
enum MediaStatus {
PENDING
PROCESSING
COMPLETE
REJECTED
FAILED
}
model MediaQueue {
id String @id @default(cuid())
status MediaStatus @default(PENDING)
sourceType String
sourceId String
stagingKey String
originalFilename String
mimeType String
fileSize Int
mediaId String?
media Media? @relation(fields: [mediaId], references: [id], onDelete: SetNull)
attempts Int @default(0)
maxAttempts Int @default(3)
errorMsg String?
moderationResult String?
moderationDetails Json?
lockedAt DateTime?
lockedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, createdAt])
@@index([sourceType, sourceId])
}
model Media {
id String @id @default(cuid())
urls Json @default("{}")
originalUrl String
width Int
height Int
format String
aspectRatio String
blurhash String
focalPoint Json @default("{\"x\": 0.5, \"y\": 0.5}")
sourceType String
sourceId String
originalFilename String
mimeType String
fileSize Int
exifStripped Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
queue MediaQueue[]
@@index([sourceType, sourceId])
}2. Register Plugin
import Fastify from 'fastify';
import xMedia from '@xenterprises/fastify-xmedia';
import multipart from '@fastify/multipart';
import { PrismaClient } from '@prisma/client';
const fastify = Fastify();
const prisma = new PrismaClient();
await fastify.register(multipart);
await fastify.register(xMedia, {
// R2 Configuration
r2: {
endpoint: process.env.R2_ENDPOINT,
region: 'auto',
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
bucket: process.env.R2_BUCKET,
},
// Database connection
db: prisma,
// Optional: Content moderation
moderation: {
provider: 'rekognition', // or 'vision', 'sightengine', etc.
apiKey: process.env.MODERATION_API_KEY,
// provider-specific options...
},
// Optional: Customize variant specs
variants: {
xs: { width: 80, height: 80, fit: 'cover' },
sm: { width: 200, height: 200, fit: 'cover' },
md: { width: 600, height: null, fit: 'inside' },
lg: { width: 1200, height: null, fit: 'inside' },
xl: { width: 1920, height: null, fit: 'inside' },
'2xl': { width: 2560, height: null, fit: 'inside' },
},
// Optional: Worker configuration
worker: {
enabled: true,
pollInterval: 5000, // 5 seconds
maxAttempts: 3,
lockTimeout: 300000, // 5 minutes
failOnError: false,
},
// Optional: Storage paths
stagingPath: 'staging',
mediaPath: 'media',
originalsPath: 'originals',
// Optional: Limits
maxFileSize: 50 * 1024 * 1024, // 50MB
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
});API Endpoints
Upload Image
POST /media/upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="photo.jpg"
Content-Type: image/jpeg
[binary image data]
------WebKitFormBoundary
Content-Disposition: form-data; name="sourceType"
avatar
------WebKitFormBoundary
Content-Disposition: form-data; name="sourceId"
user123
------WebKitFormBoundary--Response: 202 Accepted
{
"jobId": "clh7k9w1j0000nv8zk9k9k9k9",
"message": "File uploaded. Processing started.",
"statusUrl": "/media/status/clh7k9w1j0000nv8zk9k9k9k9"
}Check Processing Status
GET /media/status/:jobId HTTP/1.1Responses:
While Processing (202):
{
"jobId": "clh7k9w1j0000nv8zk9k9k9k9",
"status": "PROCESSING",
"sourceType": "avatar",
"sourceId": "user123",
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:02Z"
}When Complete (200):
{
"jobId": "clh7k9w1j0000nv8zk9k9k9k9",
"status": "COMPLETE",
"sourceType": "avatar",
"sourceId": "user123",
"media": {
"id": "media-1705314600000-abc123",
"urls": {
"xs": "https://r2.example.com/media/avatar/user123/...-xs.webp",
"sm": "https://r2.example.com/media/avatar/user123/...-sm.webp",
"md": "https://r2.example.com/media/avatar/user123/...-md.webp"
},
"originalUrl": "https://r2.example.com/originals/avatar/user123/.../original.jpg",
"width": 2000,
"height": 2000,
"aspectRatio": "1:1",
"blurhash": "UeKUpMxua4t757oJodS3_3kCMd9F6p",
"focalPoint": { "x": 0.5, "y": 0.5 }
}
}When Rejected (400):
{
"jobId": "clh7k9w1j0000nv8zk9k9k9k9",
"status": "REJECTED",
"reason": "REJECTED",
"moderationDetails": {
"flags": ["adult", "violence"],
"confidence": { "adult": 0.95 }
}
}When Failed (500):
{
"jobId": "clh7k9w1j0000nv8zk9k9k9k9",
"status": "FAILED",
"error": "Failed to download from R2",
"attempts": 3
}Source Types & Variant Presets
The plugin comes with predefined source types and their variant presets:
| Source Type | Variants | Use Case |
|---|---|---|
| avatar | xs, sm | User/band profile pictures |
| member_photo | xs, sm, md | Member directory images |
| gallery | md, lg, xl | Gallery display images |
| hero | lg, xl, 2xl | Hero/banner backgrounds |
| content | md, lg | Article/post images |
Each source type generates only the specified variants. For example, avatar uploads will create xs.webp and sm.webp files.
Frontend Usage
React Example
import { useEffect, useState } from 'react';
function AvatarUpload({ userId }) {
const [jobId, setJobId] = useState(null);
const [status, setStatus] = useState(null);
const [loading, setLoading] = useState(false);
const handleUpload = async (file) => {
const formData = new FormData();
formData.append('file', file);
formData.append('sourceType', 'avatar');
formData.append('sourceId', userId);
const response = await fetch('/media/upload', {
method: 'POST',
body: formData,
});
const data = await response.json();
setJobId(data.jobId);
};
useEffect(() => {
if (!jobId) return;
const checkStatus = async () => {
const response = await fetch(`/media/status/${jobId}`);
const data = await response.json();
setStatus(data);
if (data.status === 'COMPLETE') {
setLoading(false);
}
};
const interval = setInterval(checkStatus, 2000);
return () => clearInterval(interval);
}, [jobId]);
return (
<div>
<input type="file" onChange={(e) => handleUpload(e.target.files[0])} />
{status?.media && (
<img src={status.media.urls.sm} alt="Avatar" />
)}
{status?.status === 'PROCESSING' && <p>Processing...</p>}
{status?.status === 'REJECTED' && <p>Image rejected: {status.reason}</p>}
</div>
);
}Nuxt Example
<template>
<div>
<input type="file" @change="handleUpload" />
<div v-if="media">
<img :src="media.urls.sm" alt="Avatar" />
<div
v-if="media.blurhash"
:style="{ backgroundColor: blurhashColor }"
class="blur-placeholder"
/>
</div>
<p v-if="status === 'PROCESSING'">Processing...</p>
<p v-if="status === 'REJECTED'">Image rejected</p>
</div>
</template>
<script setup>
import { ref } from 'vue';
const jobId = ref(null);
const media = ref(null);
const status = ref(null);
const handleUpload = async (event) => {
const file = event.target.files[0];
const formData = new FormData();
formData.append('file', file);
formData.append('sourceType', 'avatar');
formData.append('sourceId', 'user123');
const response = await fetch('/media/upload', {
method: 'POST',
body: formData,
});
const { jobId: id } = await response.json();
jobId.value = id;
pollStatus();
};
const pollStatus = async () => {
const response = await fetch(`/media/status/${jobId.value}`);
const data = await response.json();
status.value = data.status;
if (data.status === 'COMPLETE') {
media.value = data.media;
} else if (data.status !== 'PROCESSING') {
setTimeout(pollStatus, 2000);
}
};
</script>Processing Pipeline
- Upload - File received at
/media/upload - Queue - Job created in database with
PENDINGstatus - Worker Polling - Worker finds next job and locks it
- Download - File downloaded from staging bucket
- EXIF Strip - Metadata removed while preserving orientation
- Moderation - Content checked (if enabled)
- Variants - WebP variants generated at specified sizes
- Blurhash - Loading placeholder generated
- Upload - Variants and original uploaded to media bucket
- Store - Media record created with URLs and metadata
- Cleanup - Staging file deleted
- Complete - Job status updated to
COMPLETE
Configuration
R2 Setup
Create R2 bucket
Generate API token
Set CORS policy on bucket:
{ "CORSRules": [ { "AllowedOrigins": ["https://yoursite.com"], "AllowedMethods": ["GET"], "AllowedHeaders": ["*"], "MaxAgeSeconds": 3600 } ] }Set lifecycle policy to clean staging:
Delete objects in /staging/ older than 1 day
Environment Variables
# R2
R2_ENDPOINT=https://[account-id].r2.cloudflarestorage.com
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=my-media-bucket
# Database (Prisma)
DATABASE_URL=postgresql://...
# Moderation (optional)
MODERATION_PROVIDER=rekognition
MODERATION_API_KEY=...Testing
npm testPerformance Tips
- 🚀 Direct Upload: Consider presigned URLs for large files to bypass server bandwidth
- 🎯 CDN: Put R2 behind Cloudflare CDN for caching
- ⚡ Worker Pool: Run multiple worker processes for faster processing
- 📊 Monitoring: Monitor
MediaQueuetable for stuck jobs - 🧹 Cleanup: Run cleanup tasks regularly to remove orphaned files
Troubleshooting
Jobs stuck in PROCESSING
Jobs are automatically recovered if locked > 5 minutes. To manually recover:
import { recoverStaleLocks } from '@xenterprises/fastify-xmedia/workers/processor';
await recoverStaleLocks(prisma, 5 * 60 * 1000);Missing variants
Check that sourceType is in getVariantPresets(). Custom source types require variant config.
R2 upload failures
- Verify credentials and bucket name
- Check CORS policy
- Ensure endpoint URL is correct
Future Enhancements
- [ ] Direct browser-to-R2 uploads (presigned URLs)
- [ ] Video support with thumbnails
- [ ] Audio waveform generation
- [ ] Batch uploads
- [ ] AVIF format support
- [ ] Face detection for smart cropping
- [ ] Custom image filters/transforms
- [ ] Analytics and usage tracking
License
ISC
