npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/multipart

Setup

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.1

Responses:

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

  1. Upload - File received at /media/upload
  2. Queue - Job created in database with PENDING status
  3. Worker Polling - Worker finds next job and locks it
  4. Download - File downloaded from staging bucket
  5. EXIF Strip - Metadata removed while preserving orientation
  6. Moderation - Content checked (if enabled)
  7. Variants - WebP variants generated at specified sizes
  8. Blurhash - Loading placeholder generated
  9. Upload - Variants and original uploaded to media bucket
  10. Store - Media record created with URLs and metadata
  11. Cleanup - Staging file deleted
  12. Complete - Job status updated to COMPLETE

Configuration

R2 Setup

  1. Create R2 bucket

  2. Generate API token

  3. Set CORS policy on bucket:

    {
      "CORSRules": [
        {
          "AllowedOrigins": ["https://yoursite.com"],
          "AllowedMethods": ["GET"],
          "AllowedHeaders": ["*"],
          "MaxAgeSeconds": 3600
        }
      ]
    }
  4. 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 test

Performance 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 MediaQueue table 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