X Enterprises

fastify-ximagepipeline

Async image upload pipeline for Fastify — EXIF stripping, content moderation, WebP variant generation, blurhash placeholders, and R2/S3 storage with a background job queue.

fastify-ximagepipeline

Fastify plugin for async image upload processing — EXIF stripping, optional content moderation, WebP variant generation, blurhash placeholders, and Cloudflare R2/S3 storage. Uploads land in a staging prefix; a background worker polls a Prisma job queue and processes each image asynchronously.

Installation

npm install @xenterprises/fastify-ximagepipeline
# Peer deps
npm install @fastify/multipart fastify

Register @fastify/multipart 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, // consumer owns env access
    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 "file=@photo.jpg" -F "sourceType=avatar" -F "sourceId=user-123" \
  http://localhost:3000/image-pipeline/upload

Options

Plugin Options

NameTypeDefaultRequiredDescription
r2objectYesR2/S3 connection config
dbPrismaClientYesPrisma-like client exposing mediaQueue and media models
s3Clientobjectcreated from r2NoPre-built S3-compatible client (must expose send(command)); created once and reused
moderationobjectnullNoContent moderation config
variantsobject6 sizesNoVariant dimension specs (xs/sm/md/lg/xl/2xl)
sourceTypesobject5 typesNoPer-source-type processing config; uploads are validated against these keys. Custom source types are fully supported — providing this option replaces the defaults entirely
workerobjectenabledNoBackground worker settings
stagingPathstring"staging"NoR2 prefix for staging uploads
mediaPathstring"media"NoR2 prefix for processed media
originalsPathstring"originals"NoR2 prefix for original files
maxFileSizenumber52428800NoMax upload size in bytes (50 MB)
allowedMimeTypesstring[]jpeg/png/webp/gifNoAccepted MIME types

r2 Config

FieldTypeRequiredDescription
endpointstringYesR2 or S3-compatible endpoint URL
accessKeyIdstringYesAccess key
secretAccessKeystringYesSecret key
bucketstringYesBucket name
regionstringNoRegion (default "auto" for R2)

worker Config

FieldTypeDefaultDescription
enabledbooleantrueEnable/disable background processing
pollIntervalnumber5000Poll interval in ms
maxAttemptsnumber3Max retry attempts per job
lockTimeoutnumber300000Stale lock timeout in ms (5 min)
failOnErrorbooleantrueThrow if worker fails to start
timersobjectglobalsTimer injection { setInterval, clearInterval } (testing)

moderation Config

FieldTypeDescription
handlerasync (buffer, config) => { passed, flags, confidence }Custom moderation function — call AWS Rekognition, Google Vision, etc. If omitted, all images pass.

Default Variants

NameWidthHeightFitUse Case
xs8080coverTiny thumbnails, avatars
sm200200coverThumbnails, lists
md600autoinsideContent images, cards
lg1200autoinsideDetail views
xl1920autoinsideFull-width banners
2xl2560autoinsideRetina/4K displays

Default Source Types

TypeVariantsQualityStore Original
avatarxs, sm85Yes
member_photoxs, sm, md85Yes
gallerymd, lg, xl85No
herolg, xl, 2xl80No
contentmd, lg85Yes

Routes & Methods

Routes registered automatically at /image-pipeline/:

Decorator methods on fastify.xImagePipeline:

Exported Utilities

@xenterprises/fastify-ximagepipeline/image

import { stripExif, generateVariants, generateBlurhash } from "@xenterprises/fastify-ximagepipeline/image";
FunctionDescription
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 per spec
generateBlurhash(buffer)Create 4×3 component blurhash string
calculateFitDimensions(srcW, srcH, maxW, maxH)Aspect-ratio-preserving resize dimensions
getAspectRatio(width, height)Ratio string e.g. "16:9"
validateImage(buffer, options?)Validate dimensions and format
processImage(buffer, sourceType, config)Full pipeline: strip → metadata → variants → blurhash

@xenterprises/fastify-ximagepipeline/s3

import { uploadToS3, deleteFromS3 } from "@xenterprises/fastify-ximagepipeline/s3";
FunctionDescription
initializeS3Client(config)Create S3Client for R2/AWS
uploadToS3(client, bucket, key, buffer, options?)Upload with metadata and 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 1 h)
getPublicUrl(r2Config, key)Generate public URL
batchDeleteFromS3(client, bucket, prefix)Delete up to 1000 objects by prefix

Error Reference

Registration failures throw real Errors in the suite-standard format — plugin name, option, type, and a usage example. Route handlers return status-coded JSON errors and decorator methods throw, both with the [xImagePipeline] prefix.

ErrorCause
ximagepipeline: missing required option `r2` (object), e.g. `app.register(xImagePipeline, { r2: {...}, db: prisma })` Missing r2 option at registration
ximagepipeline: missing required option `db` (Prisma client with mediaQueue and media models), e.g. `app.register(xImagePipeline, { r2: {...}, db: prisma })` Missing db option at registration
ximagepipeline: option `r2` must include `endpoint`, `accessKeyId`, `secretAccessKey` and `bucket` (non-empty strings)Incomplete R2 config
ximagepipeline: option `db` must be a Prisma-like client exposing `mediaQueue` and `media` modelsdb missing the required models
ximagepipeline: option `s3Client` / `moderation.handler` / `worker.*` / `maxFileSize` / `allowedMimeTypes` must be …Any other invalid option — all validated up front with the same message format
[xImagePipeline] No file providedUpload request has no file (400)
[xImagePipeline] File type {mime} not allowed. Allowed types: …MIME type not in allowedMimeTypes (400)
[xImagePipeline] sourceType and sourceId are requiredMissing form fields on upload (400)
[xImagePipeline] Unknown sourceType: {type}. Allowed types: …sourceType is not a configured sourceTypes key — the defaults, or custom types passed via the sourceTypes option (400)
[xImagePipeline] File too large. Maximum size: {n}MBFile exceeds maxFileSize (413)
[xImagePipeline] Failed to upload file to storageR2/S3 upload error (500)
[xImagePipeline] Failed to create processing jobDatabase error during job creation (500; staging object is cleaned up)
[xImagePipeline] Job not foundStatus route called with an unknown jobId (404)
[xImagePipeline] Media not found: {id}deleteMedia called with an unknown media ID

Environment Variables

The plugin never reads process.env — the consumer owns env access and passes the values into app.register(xImagePipeline, { ... }) (see Quick Start). These are the variables a consumer typically sets to feed that registration; "Required" means required to build a valid options object, not read by the plugin itself.

VariableRequiredDescription
R2_ENDPOINTYesR2 or S3-compatible endpoint URL
R2_ACCESS_KEY_IDYesStorage access key
R2_SECRET_ACCESS_KEYYesStorage secret key
R2_BUCKETYesStorage bucket name
DATABASE_URLYesPrisma database connection string (used by the consumer's own Prisma client)

Database Schema

Requires two Prisma models (see SCHEMA.prisma in the package):

  • MediaQueue — job queue: PENDING → PROCESSING → COMPLETE / REJECTED / FAILED
  • Media — processed records with variant URLs, dimensions, blurhash, and source metadata

How It Works

Upload: Client sends multipart POST. The file is validated (type, size, sourceType) and uploaded to the R2 staging prefix. A MediaQueue row is created with PENDING status; the route returns a jobId immediately.

Processing: A background worker polls MediaQueue for PENDING jobs using pessimistic locking. For each job it downloads from staging, strips EXIF, runs content moderation (if a handler is configured), generates WebP variants per sourceType config, produces a blurhash placeholder, uploads variants and optional originals to R2, creates a Media record, marks the job COMPLETE, and deletes the staging file.

Retrieval: The client polls GET /image-pipeline/status/:jobId. On COMPLETE the response includes all variant URLs, blurhash, and dimensions.

Retry: Failed jobs are retried up to maxAttempts. Stale locks (worker crash) are automatically recovered after lockTimeout.

AI Context

package: "@xenterprises/fastify-ximagepipeline"
type: fastify-plugin
use-when: Async image upload pipeline — EXIF stripping, WebP variant generation, blurhash, content moderation, R2/S3 storage with background job queue
decorator: fastify.xImagePipeline (getStatus, deleteMedia, listMedia, getVariantPresets, getSourceTypes, getVariants)
routes: POST /image-pipeline/upload, GET /image-pipeline/status/:jobId
env: R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, DATABASE_URL
requires: Prisma client with MediaQueue and Media models; @fastify/multipart registered before this plugin
utility-exports: "@xenterprises/fastify-ximagepipeline/image" (image processing), "@xenterprises/fastify-ximagepipeline/s3" (S3 helpers)
Copyright © 2026