@xenterprises/fastify-ximagepipeline
v1.2.1
Published
Fastify plugin for image uploads with EXIF stripping, moderation, variant generation, and R2 storage with job queue
Readme
@xenterprises/fastify-ximagepipeline
Fastify plugin for image uploads with EXIF stripping, variant generation, blurhash placeholders, and R2/S3 storage — powered by a background job queue.
Install
npm install @xenterprises/fastify-ximagepipelinePeer dependencies: fastify ^5.0.0, @fastify/multipart (register before this plugin)
Quick Start
import Fastify from "fastify";
import multipart from "@fastify/multipart";
import xImagePipeline from "@xenterprises/fastify-ximagepipeline";
const fastify = Fastify({ logger: true });
await fastify.register(multipart);
await fastify.register(xImagePipeline, {
r2: {
endpoint: process.env.R2_ENDPOINT,
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
bucket: process.env.R2_BUCKET,
},
db: prisma, // Prisma client with MediaQueue + Media models
});
await fastify.listen({ port: 3000 });Upload an image:
curl -F "[email protected]" \
-F "sourceType=avatar" \
-F "sourceId=user-123" \
http://localhost:3000/image-pipeline/uploadPlugin Options
| Option | Type | Default | Required | Description |
|--------|------|---------|----------|-------------|
| r2 | Object | — | Yes | R2/S3 connection config (see below) |
| db | Object | — | Yes | Prisma client with mediaQueue and media models |
| moderation | Object | null | No | Content moderation config with handler function |
| variants | Object | 6 sizes | No | Variant dimension specs (xs/sm/md/lg/xl/2xl) |
| sourceTypes | Object | 5 types | No | Per-source-type processing config |
| worker | Object | enabled | No | Background worker settings |
| stagingPath | string | "staging" | No | R2 prefix for staging uploads |
| mediaPath | string | "media" | No | R2 prefix for processed media |
| originalsPath | string | "originals" | No | R2 prefix for original files |
| maxFileSize | number | 52428800 | No | Max upload size in bytes (50MB) |
| allowedMimeTypes | string[] | jpeg/png/webp/gif | No | Accepted MIME types |
r2 Config
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| endpoint | string | Yes | R2 or S3-compatible endpoint URL |
| accessKeyId | string | Yes | Access key |
| secretAccessKey | string | Yes | Secret key |
| bucket | string | Yes | Bucket name |
| region | string | No | Region (default "auto" for R2) |
worker Config
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| enabled | boolean | true | Enable/disable background processing |
| pollInterval | number | 5000 | Poll interval in ms |
| maxAttempts | number | 3 | Max retry attempts per job |
| lockTimeout | number | 300000 | Lock timeout in ms (5 min) |
| failOnError | boolean | true | Throw if worker fails to start |
moderation Config
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| handler | function | No | async (buffer, config) => { passed, flags, confidence } |
If no handler is provided, all images are approved. Provide a handler to call AWS Rekognition, Google Vision, or any moderation API.
Default Variants
| Name | Width | Height | Fit | Use Case |
|------|-------|--------|-----|----------|
| xs | 80 | 80 | cover | Tiny thumbnails, avatars |
| sm | 200 | 200 | cover | Thumbnails, lists |
| md | 600 | auto | inside | Content images, cards |
| lg | 1200 | auto | inside | Detail views |
| xl | 1920 | auto | inside | Full-width banners |
| 2xl | 2560 | auto | inside | Retina/4K displays |
Default Source Types
| Type | Variants | Quality | Store Original |
|------|----------|---------|----------------|
| avatar | xs, sm | 85 | Yes |
| member_photo | xs, sm, md | 85 | Yes |
| gallery | md, lg, xl | 85 | No |
| hero | lg, xl, 2xl | 80 | No |
| content | md, lg | 85 | Yes |
API Endpoints
POST /image-pipeline/upload
Upload an image for processing. Requires multipart form data.
Fields:
file(binary, required) — image filesourceType(string, required) — one of the configured source typessourceId(string, required) — identifier for the source entity
Response (202 Accepted):
{
"jobId": "clx1234...",
"message": "File uploaded. Processing started.",
"statusUrl": "/image-pipeline/status/clx1234..."
}GET /image-pipeline/status/:jobId
Check job processing status.
| Status | HTTP Code | Extra Fields |
|--------|-----------|--------------|
| PENDING | 202 | — |
| PROCESSING | 202 | — |
| COMPLETE | 200 | media object with urls, blurhash, dimensions |
| REJECTED | 400 | reason, moderationDetails |
| FAILED | 500 | error, attempts |
Complete response example:
{
"jobId": "clx1234...",
"status": "COMPLETE",
"media": {
"id": "media-abc...",
"urls": { "xs": "https://cdn.../xs.webp", "sm": "https://cdn.../sm.webp" },
"originalUrl": "https://cdn.../original.jpg",
"width": 1920,
"height": 1080,
"aspectRatio": "16:9",
"blurhash": "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
"focalPoint": { "x": 0.5, "y": 0.5 }
}
}Decorated Properties
The plugin decorates fastify.xImagePipeline with:
| Method | Signature | Description |
|--------|-----------|-------------|
| getStatus | (jobId: string) => Promise<Object\|null> | Get job with media relation |
| deleteMedia | (mediaId: string) => Promise<{ deleted, r2Deleted }> | Delete media + R2 objects |
| listMedia | (sourceType, sourceId) => Promise<Object[]> | List media by source |
| getVariantPresets | () => Object | Get source type → variant name mapping |
| getSourceTypes | () => Object | Get source type configurations |
| getVariants | () => Object | Get variant dimension specs |
Exported Utilities
@xenterprises/fastify-ximagepipeline/image
| Function | Description |
|----------|-------------|
| stripExif(buffer) | Remove EXIF metadata, preserve orientation |
| getImageMetadata(buffer) | Extract width, height, format, colorspace, hasAlpha, density |
| compressToJpeg(buffer, quality?) | Compress to JPEG (mozjpeg, default quality 85) |
| generateVariants(buffer, specs, sourceType, quality?) | Generate WebP variants |
| generateBlurhash(buffer) | Create 4x3 component blurhash |
| calculateFitDimensions(srcW, srcH, maxW, maxH) | Aspect-ratio-preserving resize calc |
| getAspectRatio(width, height) | Return ratio string like "16:9" |
| validateImage(buffer, options?) | Validate dimensions and format |
| processImage(buffer, sourceType, config) | Full pipeline: strip, metadata, variants, blurhash |
@xenterprises/fastify-ximagepipeline/s3
| Function | Description |
|----------|-------------|
| initializeS3Client(config) | Create S3Client for R2/AWS |
| uploadToS3(client, bucket, key, buffer, options?) | Upload with metadata + cache headers |
| downloadFromS3(client, bucket, key) | Download buffer |
| deleteFromS3(client, bucket, key) | Delete single object |
| listFromS3(client, bucket, prefix) | List objects by prefix |
| getSignedUrlForS3(client, bucket, key, expiresIn?) | Generate signed URL (default 1h) |
| getPublicUrl(r2Config, key) | Generate public URL |
| batchDeleteFromS3(client, bucket, prefix) | Delete up to 1000 objects by prefix |
Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| R2_ENDPOINT | Yes | R2/S3-compatible endpoint URL |
| R2_ACCESS_KEY_ID | Yes | Storage access key |
| R2_SECRET_ACCESS_KEY | Yes | Storage secret key |
| R2_BUCKET | Yes | Storage bucket name |
| DATABASE_URL | Yes | Prisma database connection string |
Error Reference
All errors are prefixed with [xImagePipeline].
| Error | When |
|-------|------|
| R2 configuration is required | Missing r2 option |
| Database instance (Prisma client) is required | Missing db option |
| R2 configuration must include: endpoint, accessKeyId, secretAccessKey, bucket | Incomplete R2 config |
| No file provided | Upload request has no file |
| File type {mime} not allowed | Upload MIME type not in allowedMimeTypes |
| sourceType and sourceId are required | Missing form fields on upload |
| Unknown sourceType: {type} | sourceType not in variant presets |
| File too large. Maximum size: {n}MB | File exceeds maxFileSize |
| Failed to upload file to storage | R2 upload error |
| Failed to create processing job | Database error during job creation |
| Media not found: {id} | deleteMedia called with invalid ID |
Database Schema
The plugin requires two Prisma models. See SCHEMA.prisma in the package for the full schema.
MediaQueue — job queue for async processing (PENDING → PROCESSING → COMPLETE/REJECTED/FAILED)
Media — processed media records with variant URLs, dimensions, blurhash, and metadata
How It Works
Upload: Client sends multipart POST with image + sourceType + sourceId. The file is validated (type, size) and uploaded to R2 staging. A
MediaQueuejob is created with PENDING status.Processing: A background worker polls for PENDING jobs using pessimistic locking (prevents duplicate processing). For each job:
- Downloads from staging
- Strips EXIF metadata (preserves orientation)
- Extracts dimensions and format
- Runs content moderation (if configured)
- Generates WebP variants per sourceType config
- Generates blurhash placeholder
- Uploads variants (and optional original) to R2
- Creates
Mediarecord with URLs and metadata - Marks job COMPLETE and cleans up staging
Retrieval: Client polls the status endpoint. On COMPLETE, the response includes all variant URLs, blurhash, and dimensions for immediate frontend use.
Retry: Failed jobs are retried up to
maxAttempts. Stale locks (worker crashed) are automatically recovered afterlockTimeout.
License
UNLICENSED
