@xenterprises/fastify-ximagepipeline 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,488 +1,258 @@
1
- # xMedia Plugin for Fastify v5
1
+ # @xenterprises/fastify-ximagepipeline
2
2
 
3
- Fastify plugin for handling image uploads with automatic EXIF stripping, content moderation, WebP variant generation, and Cloudflare R2 storage using a job queue pattern.
3
+ Fastify plugin for image uploads with EXIF stripping, variant generation, blurhash placeholders, and R2/S3 storage powered by a background job queue.
4
4
 
5
- ## Features
6
-
7
- ✨ **Core Capabilities**
8
- - 🖼️ **Image Upload** - Multipart file upload with validation
9
- - 🔍 **EXIF Stripping** - Remove metadata while preserving orientation
10
- - 🎨 **Variant Generation** - Automatic WebP variants at multiple sizes
11
- - 📦 **R2 Storage** - Direct integration with Cloudflare R2 (S3-compatible)
12
- - ⏳ **Job Queue** - Database-backed processing queue with retry logic
13
- - 🔒 **Content Moderation** - Pluggable moderation API support
14
- - 🎯 **Focal Points** - Smart cropping hints for UI
15
- - 📊 **Blurhash** - Loading placeholders for instant UI feedback
16
- - 🧹 **Cleanup** - Automatic staging cleanup and stale job recovery
17
-
18
- ## Installation
5
+ ## Install
19
6
 
20
7
  ```bash
21
- npm install @xenterprises/fastify-xmedia @fastify/multipart
8
+ npm install @xenterprises/fastify-ximagepipeline
22
9
  ```
23
10
 
24
- ## Setup
25
-
26
- ### 1. Add Prisma Models
27
-
28
- Add these models to your `schema.prisma`:
29
-
30
- ```prisma
31
- enum MediaStatus {
32
- PENDING
33
- PROCESSING
34
- COMPLETE
35
- REJECTED
36
- FAILED
37
- }
38
-
39
- model MediaQueue {
40
- id String @id @default(cuid())
41
- status MediaStatus @default(PENDING)
42
- sourceType String
43
- sourceId String
44
- stagingKey String
45
- originalFilename String
46
- mimeType String
47
- fileSize Int
48
- mediaId String?
49
- media Media? @relation(fields: [mediaId], references: [id], onDelete: SetNull)
50
- attempts Int @default(0)
51
- maxAttempts Int @default(3)
52
- errorMsg String?
53
- moderationResult String?
54
- moderationDetails Json?
55
- lockedAt DateTime?
56
- lockedBy String?
57
- createdAt DateTime @default(now())
58
- updatedAt DateTime @updatedAt
59
-
60
- @@index([status, createdAt])
61
- @@index([sourceType, sourceId])
62
- }
11
+ **Peer dependencies:** `fastify ^5.0.0`, `@fastify/multipart` (register before this plugin)
63
12
 
64
- model Media {
65
- id String @id @default(cuid())
66
- urls Json @default("{}")
67
- originalUrl String
68
- width Int
69
- height Int
70
- format String
71
- aspectRatio String
72
- blurhash String
73
- focalPoint Json @default("{\"x\": 0.5, \"y\": 0.5}")
74
- sourceType String
75
- sourceId String
76
- originalFilename String
77
- mimeType String
78
- fileSize Int
79
- exifStripped Boolean @default(true)
80
- createdAt DateTime @default(now())
81
- updatedAt DateTime @updatedAt
82
- queue MediaQueue[]
83
-
84
- @@index([sourceType, sourceId])
85
- }
86
- ```
13
+ ## Quick Start
87
14
 
88
- ### 2. Register Plugin
89
-
90
- ```javascript
91
- import Fastify from 'fastify';
92
- import xMedia from '@xenterprises/fastify-xmedia';
93
- import multipart from '@fastify/multipart';
94
- import { PrismaClient } from '@prisma/client';
95
-
96
- const fastify = Fastify();
97
- const prisma = new PrismaClient();
15
+ ```js
16
+ import Fastify from "fastify";
17
+ import multipart from "@fastify/multipart";
18
+ import xImagePipeline from "@xenterprises/fastify-ximagepipeline";
98
19
 
20
+ const fastify = Fastify({ logger: true });
99
21
  await fastify.register(multipart);
100
-
101
- await fastify.register(xMedia, {
102
- // R2 Configuration
22
+ await fastify.register(xImagePipeline, {
103
23
  r2: {
104
24
  endpoint: process.env.R2_ENDPOINT,
105
- region: 'auto',
106
25
  accessKeyId: process.env.R2_ACCESS_KEY_ID,
107
26
  secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
108
27
  bucket: process.env.R2_BUCKET,
109
28
  },
110
-
111
- // Database connection
112
- db: prisma,
113
-
114
- // Optional: Content moderation
115
- moderation: {
116
- provider: 'rekognition', // or 'vision', 'sightengine', etc.
117
- apiKey: process.env.MODERATION_API_KEY,
118
- // provider-specific options...
119
- },
120
-
121
- // Optional: Customize variant specs
122
- variants: {
123
- xs: { width: 80, height: 80, fit: 'cover' },
124
- sm: { width: 200, height: 200, fit: 'cover' },
125
- md: { width: 600, height: null, fit: 'inside' },
126
- lg: { width: 1200, height: null, fit: 'inside' },
127
- xl: { width: 1920, height: null, fit: 'inside' },
128
- '2xl': { width: 2560, height: null, fit: 'inside' },
129
- },
130
-
131
- // Optional: Worker configuration
132
- worker: {
133
- enabled: true,
134
- pollInterval: 5000, // 5 seconds
135
- maxAttempts: 3,
136
- lockTimeout: 300000, // 5 minutes
137
- failOnError: false,
138
- },
139
-
140
- // Optional: Storage paths
141
- stagingPath: 'staging',
142
- mediaPath: 'media',
143
- originalsPath: 'originals',
144
-
145
- // Optional: Limits
146
- maxFileSize: 50 * 1024 * 1024, // 50MB
147
- allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
29
+ db: prisma, // Prisma client with MediaQueue + Media models
148
30
  });
149
- ```
150
31
 
151
- ## API Endpoints
32
+ await fastify.listen({ port: 3000 });
33
+ ```
152
34
 
153
- ### Upload Image
35
+ Upload an image:
154
36
 
155
- ```http
156
- POST /media/upload HTTP/1.1
37
+ ```bash
38
+ curl -F "file=@photo.jpg" \
39
+ -F "sourceType=avatar" \
40
+ -F "sourceId=user-123" \
41
+ http://localhost:3000/image-pipeline/upload
42
+ ```
157
43
 
158
- Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
44
+ ## Plugin Options
45
+
46
+ | Option | Type | Default | Required | Description |
47
+ |--------|------|---------|----------|-------------|
48
+ | `r2` | `Object` | — | Yes | R2/S3 connection config (see below) |
49
+ | `db` | `Object` | — | Yes | Prisma client with `mediaQueue` and `media` models |
50
+ | `moderation` | `Object` | `null` | No | Content moderation config with `handler` function |
51
+ | `variants` | `Object` | 6 sizes | No | Variant dimension specs (xs/sm/md/lg/xl/2xl) |
52
+ | `sourceTypes` | `Object` | 5 types | No | Per-source-type processing config |
53
+ | `worker` | `Object` | enabled | No | Background worker settings |
54
+ | `stagingPath` | `string` | `"staging"` | No | R2 prefix for staging uploads |
55
+ | `mediaPath` | `string` | `"media"` | No | R2 prefix for processed media |
56
+ | `originalsPath` | `string` | `"originals"` | No | R2 prefix for original files |
57
+ | `maxFileSize` | `number` | `52428800` | No | Max upload size in bytes (50MB) |
58
+ | `allowedMimeTypes` | `string[]` | jpeg/png/webp/gif | No | Accepted MIME types |
59
+
60
+ ### `r2` Config
61
+
62
+ | Field | Type | Required | Description |
63
+ |-------|------|----------|-------------|
64
+ | `endpoint` | `string` | Yes | R2 or S3-compatible endpoint URL |
65
+ | `accessKeyId` | `string` | Yes | Access key |
66
+ | `secretAccessKey` | `string` | Yes | Secret key |
67
+ | `bucket` | `string` | Yes | Bucket name |
68
+ | `region` | `string` | No | Region (default `"auto"` for R2) |
69
+
70
+ ### `worker` Config
71
+
72
+ | Field | Type | Default | Description |
73
+ |-------|------|---------|-------------|
74
+ | `enabled` | `boolean` | `true` | Enable/disable background processing |
75
+ | `pollInterval` | `number` | `5000` | Poll interval in ms |
76
+ | `maxAttempts` | `number` | `3` | Max retry attempts per job |
77
+ | `lockTimeout` | `number` | `300000` | Lock timeout in ms (5 min) |
78
+ | `failOnError` | `boolean` | `true` | Throw if worker fails to start |
79
+
80
+ ### `moderation` Config
81
+
82
+ | Field | Type | Required | Description |
83
+ |-------|------|----------|-------------|
84
+ | `handler` | `function` | No | `async (buffer, config) => { passed, flags, confidence }` |
85
+
86
+ If no handler is provided, all images are approved. Provide a handler to call AWS Rekognition, Google Vision, or any moderation API.
87
+
88
+ ## Default Variants
89
+
90
+ | Name | Width | Height | Fit | Use Case |
91
+ |------|-------|--------|-----|----------|
92
+ | `xs` | 80 | 80 | cover | Tiny thumbnails, avatars |
93
+ | `sm` | 200 | 200 | cover | Thumbnails, lists |
94
+ | `md` | 600 | auto | inside | Content images, cards |
95
+ | `lg` | 1200 | auto | inside | Detail views |
96
+ | `xl` | 1920 | auto | inside | Full-width banners |
97
+ | `2xl` | 2560 | auto | inside | Retina/4K displays |
98
+
99
+ ## Default Source Types
100
+
101
+ | Type | Variants | Quality | Store Original |
102
+ |------|----------|---------|----------------|
103
+ | `avatar` | xs, sm | 85 | Yes |
104
+ | `member_photo` | xs, sm, md | 85 | Yes |
105
+ | `gallery` | md, lg, xl | 85 | No |
106
+ | `hero` | lg, xl, 2xl | 80 | No |
107
+ | `content` | md, lg | 85 | Yes |
159
108
 
160
- ------WebKitFormBoundary
161
- Content-Disposition: form-data; name="file"; filename="photo.jpg"
162
- Content-Type: image/jpeg
109
+ ## API Endpoints
163
110
 
164
- [binary image data]
165
- ------WebKitFormBoundary
166
- Content-Disposition: form-data; name="sourceType"
111
+ ### `POST /image-pipeline/upload`
167
112
 
168
- avatar
169
- ------WebKitFormBoundary
170
- Content-Disposition: form-data; name="sourceId"
113
+ Upload an image for processing. Requires multipart form data.
171
114
 
172
- user123
173
- ------WebKitFormBoundary--
174
- ```
115
+ **Fields:**
116
+ - `file` (binary, required) — image file
117
+ - `sourceType` (string, required) — one of the configured source types
118
+ - `sourceId` (string, required) — identifier for the source entity
175
119
 
176
- **Response:** `202 Accepted`
120
+ **Response (202 Accepted):**
177
121
  ```json
178
122
  {
179
- "jobId": "clh7k9w1j0000nv8zk9k9k9k9",
123
+ "jobId": "clx1234...",
180
124
  "message": "File uploaded. Processing started.",
181
- "statusUrl": "/media/status/clh7k9w1j0000nv8zk9k9k9k9"
125
+ "statusUrl": "/image-pipeline/status/clx1234..."
182
126
  }
183
127
  ```
184
128
 
185
- ### Check Processing Status
129
+ ### `GET /image-pipeline/status/:jobId`
186
130
 
187
- ```http
188
- GET /media/status/:jobId HTTP/1.1
189
- ```
131
+ Check job processing status.
190
132
 
191
- **Responses:**
133
+ | Status | HTTP Code | Extra Fields |
134
+ |--------|-----------|--------------|
135
+ | PENDING | 202 | — |
136
+ | PROCESSING | 202 | — |
137
+ | COMPLETE | 200 | `media` object with urls, blurhash, dimensions |
138
+ | REJECTED | 400 | `reason`, `moderationDetails` |
139
+ | FAILED | 500 | `error`, `attempts` |
192
140
 
193
- While Processing (202):
141
+ **Complete response example:**
194
142
  ```json
195
143
  {
196
- "jobId": "clh7k9w1j0000nv8zk9k9k9k9",
197
- "status": "PROCESSING",
198
- "sourceType": "avatar",
199
- "sourceId": "user123",
200
- "createdAt": "2024-01-15T10:30:00Z",
201
- "updatedAt": "2024-01-15T10:30:02Z"
202
- }
203
- ```
204
-
205
- When Complete (200):
206
- ```json
207
- {
208
- "jobId": "clh7k9w1j0000nv8zk9k9k9k9",
144
+ "jobId": "clx1234...",
209
145
  "status": "COMPLETE",
210
- "sourceType": "avatar",
211
- "sourceId": "user123",
212
146
  "media": {
213
- "id": "media-1705314600000-abc123",
214
- "urls": {
215
- "xs": "https://r2.example.com/media/avatar/user123/...-xs.webp",
216
- "sm": "https://r2.example.com/media/avatar/user123/...-sm.webp",
217
- "md": "https://r2.example.com/media/avatar/user123/...-md.webp"
218
- },
219
- "originalUrl": "https://r2.example.com/originals/avatar/user123/.../original.jpg",
220
- "width": 2000,
221
- "height": 2000,
222
- "aspectRatio": "1:1",
223
- "blurhash": "UeKUpMxua4t757oJodS3_3kCMd9F6p",
147
+ "id": "media-abc...",
148
+ "urls": { "xs": "https://cdn.../xs.webp", "sm": "https://cdn.../sm.webp" },
149
+ "originalUrl": "https://cdn.../original.jpg",
150
+ "width": 1920,
151
+ "height": 1080,
152
+ "aspectRatio": "16:9",
153
+ "blurhash": "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
224
154
  "focalPoint": { "x": 0.5, "y": 0.5 }
225
155
  }
226
156
  }
227
157
  ```
228
158
 
229
- When Rejected (400):
230
- ```json
231
- {
232
- "jobId": "clh7k9w1j0000nv8zk9k9k9k9",
233
- "status": "REJECTED",
234
- "reason": "REJECTED",
235
- "moderationDetails": {
236
- "flags": ["adult", "violence"],
237
- "confidence": { "adult": 0.95 }
238
- }
239
- }
240
- ```
241
-
242
- When Failed (500):
243
- ```json
244
- {
245
- "jobId": "clh7k9w1j0000nv8zk9k9k9k9",
246
- "status": "FAILED",
247
- "error": "Failed to download from R2",
248
- "attempts": 3
249
- }
250
- ```
251
-
252
- ## Source Types & Variant Presets
253
-
254
- The plugin comes with predefined source types and their variant presets:
255
-
256
- | Source Type | Variants | Use Case |
257
- |---|---|---|
258
- | `avatar` | xs, sm | User/band profile pictures |
259
- | `member_photo` | xs, sm, md | Member directory images |
260
- | `gallery` | md, lg, xl | Gallery display images |
261
- | `hero` | lg, xl, 2xl | Hero/banner backgrounds |
262
- | `content` | md, lg | Article/post images |
263
-
264
- Each source type generates only the specified variants. For example, `avatar` uploads will create `xs.webp` and `sm.webp` files.
265
-
266
- ## Frontend Usage
267
-
268
- ### React Example
269
-
270
- ```jsx
271
- import { useEffect, useState } from 'react';
272
-
273
- function AvatarUpload({ userId }) {
274
- const [jobId, setJobId] = useState(null);
275
- const [status, setStatus] = useState(null);
276
- const [loading, setLoading] = useState(false);
277
-
278
- const handleUpload = async (file) => {
279
- const formData = new FormData();
280
- formData.append('file', file);
281
- formData.append('sourceType', 'avatar');
282
- formData.append('sourceId', userId);
283
-
284
- const response = await fetch('/media/upload', {
285
- method: 'POST',
286
- body: formData,
287
- });
288
-
289
- const data = await response.json();
290
- setJobId(data.jobId);
291
- };
292
-
293
- useEffect(() => {
294
- if (!jobId) return;
295
-
296
- const checkStatus = async () => {
297
- const response = await fetch(`/media/status/${jobId}`);
298
- const data = await response.json();
299
- setStatus(data);
300
-
301
- if (data.status === 'COMPLETE') {
302
- setLoading(false);
303
- }
304
- };
305
-
306
- const interval = setInterval(checkStatus, 2000);
307
- return () => clearInterval(interval);
308
- }, [jobId]);
309
-
310
- return (
311
- <div>
312
- <input type="file" onChange={(e) => handleUpload(e.target.files[0])} />
313
- {status?.media && (
314
- <img src={status.media.urls.sm} alt="Avatar" />
315
- )}
316
- {status?.status === 'PROCESSING' && <p>Processing...</p>}
317
- {status?.status === 'REJECTED' && <p>Image rejected: {status.reason}</p>}
318
- </div>
319
- );
320
- }
321
- ```
322
-
323
- ### Nuxt Example
324
-
325
- ```vue
326
- <template>
327
- <div>
328
- <input type="file" @change="handleUpload" />
329
-
330
- <div v-if="media">
331
- <img :src="media.urls.sm" alt="Avatar" />
332
- <div
333
- v-if="media.blurhash"
334
- :style="{ backgroundColor: blurhashColor }"
335
- class="blur-placeholder"
336
- />
337
- </div>
338
-
339
- <p v-if="status === 'PROCESSING'">Processing...</p>
340
- <p v-if="status === 'REJECTED'">Image rejected</p>
341
- </div>
342
- </template>
343
-
344
- <script setup>
345
- import { ref } from 'vue';
346
-
347
- const jobId = ref(null);
348
- const media = ref(null);
349
- const status = ref(null);
350
-
351
- const handleUpload = async (event) => {
352
- const file = event.target.files[0];
353
- const formData = new FormData();
354
- formData.append('file', file);
355
- formData.append('sourceType', 'avatar');
356
- formData.append('sourceId', 'user123');
357
-
358
- const response = await fetch('/media/upload', {
359
- method: 'POST',
360
- body: formData,
361
- });
362
-
363
- const { jobId: id } = await response.json();
364
- jobId.value = id;
365
- pollStatus();
366
- };
367
-
368
- const pollStatus = async () => {
369
- const response = await fetch(`/media/status/${jobId.value}`);
370
- const data = await response.json();
371
- status.value = data.status;
372
-
373
- if (data.status === 'COMPLETE') {
374
- media.value = data.media;
375
- } else if (data.status !== 'PROCESSING') {
376
- setTimeout(pollStatus, 2000);
377
- }
378
- };
379
- </script>
380
- ```
381
-
382
- ## Processing Pipeline
383
-
384
- 1. **Upload** - File received at `/media/upload`
385
- 2. **Queue** - Job created in database with `PENDING` status
386
- 3. **Worker Polling** - Worker finds next job and locks it
387
- 4. **Download** - File downloaded from staging bucket
388
- 5. **EXIF Strip** - Metadata removed while preserving orientation
389
- 6. **Moderation** - Content checked (if enabled)
390
- 7. **Variants** - WebP variants generated at specified sizes
391
- 8. **Blurhash** - Loading placeholder generated
392
- 9. **Upload** - Variants and original uploaded to media bucket
393
- 10. **Store** - Media record created with URLs and metadata
394
- 11. **Cleanup** - Staging file deleted
395
- 12. **Complete** - Job status updated to `COMPLETE`
396
-
397
- ## Configuration
398
-
399
- ### R2 Setup
400
-
401
- 1. Create R2 bucket
402
- 2. Generate API token
403
- 3. Set CORS policy on bucket:
404
- ```json
405
- {
406
- "CORSRules": [
407
- {
408
- "AllowedOrigins": ["https://yoursite.com"],
409
- "AllowedMethods": ["GET"],
410
- "AllowedHeaders": ["*"],
411
- "MaxAgeSeconds": 3600
412
- }
413
- ]
414
- }
415
- ```
416
-
417
- 4. Set lifecycle policy to clean staging:
418
- ```
419
- Delete objects in /staging/ older than 1 day
420
- ```
421
-
422
- ### Environment Variables
423
-
424
- ```bash
425
- # R2
426
- R2_ENDPOINT=https://[account-id].r2.cloudflarestorage.com
427
- R2_ACCESS_KEY_ID=...
428
- R2_SECRET_ACCESS_KEY=...
429
- R2_BUCKET=my-media-bucket
430
-
431
- # Database (Prisma)
432
- DATABASE_URL=postgresql://...
433
-
434
- # Moderation (optional)
435
- MODERATION_PROVIDER=rekognition
436
- MODERATION_API_KEY=...
437
- ```
438
-
439
- ## Testing
440
-
441
- ```bash
442
- npm test
443
- ```
444
-
445
- ## Performance Tips
446
-
447
- - 🚀 **Direct Upload**: Consider presigned URLs for large files to bypass server bandwidth
448
- - 🎯 **CDN**: Put R2 behind Cloudflare CDN for caching
449
- - ⚡ **Worker Pool**: Run multiple worker processes for faster processing
450
- - 📊 **Monitoring**: Monitor `MediaQueue` table for stuck jobs
451
- - 🧹 **Cleanup**: Run cleanup tasks regularly to remove orphaned files
452
-
453
- ## Troubleshooting
454
-
455
- ### Jobs stuck in PROCESSING
456
-
457
- Jobs are automatically recovered if locked > 5 minutes. To manually recover:
458
-
459
- ```javascript
460
- import { recoverStaleLocks } from '@xenterprises/fastify-xmedia/workers/processor';
461
-
462
- await recoverStaleLocks(prisma, 5 * 60 * 1000);
463
- ```
464
-
465
- ### Missing variants
466
-
467
- Check that sourceType is in `getVariantPresets()`. Custom source types require variant config.
468
-
469
- ### R2 upload failures
470
-
471
- - Verify credentials and bucket name
472
- - Check CORS policy
473
- - Ensure endpoint URL is correct
474
-
475
- ## Future Enhancements
476
-
477
- - [ ] Direct browser-to-R2 uploads (presigned URLs)
478
- - [ ] Video support with thumbnails
479
- - [ ] Audio waveform generation
480
- - [ ] Batch uploads
481
- - [ ] AVIF format support
482
- - [ ] Face detection for smart cropping
483
- - [ ] Custom image filters/transforms
484
- - [ ] Analytics and usage tracking
159
+ ## Decorated Properties
160
+
161
+ The plugin decorates `fastify.xImagePipeline` with:
162
+
163
+ | Method | Signature | Description |
164
+ |--------|-----------|-------------|
165
+ | `getStatus` | `(jobId: string) => Promise<Object\|null>` | Get job with media relation |
166
+ | `deleteMedia` | `(mediaId: string) => Promise<{ deleted, r2Deleted }>` | Delete media + R2 objects |
167
+ | `listMedia` | `(sourceType, sourceId) => Promise<Object[]>` | List media by source |
168
+ | `getVariantPresets` | `() => Object` | Get source type → variant name mapping |
169
+ | `getSourceTypes` | `() => Object` | Get source type configurations |
170
+ | `getVariants` | `() => Object` | Get variant dimension specs |
171
+
172
+ ## Exported Utilities
173
+
174
+ ### `@xenterprises/fastify-ximagepipeline/image`
175
+
176
+ | Function | Description |
177
+ |----------|-------------|
178
+ | `stripExif(buffer)` | Remove EXIF metadata, preserve orientation |
179
+ | `getImageMetadata(buffer)` | Extract width, height, format, colorspace, hasAlpha, density |
180
+ | `compressToJpeg(buffer, quality?)` | Compress to JPEG (mozjpeg, default quality 85) |
181
+ | `generateVariants(buffer, specs, sourceType, quality?)` | Generate WebP variants |
182
+ | `generateBlurhash(buffer)` | Create 4x3 component blurhash |
183
+ | `calculateFitDimensions(srcW, srcH, maxW, maxH)` | Aspect-ratio-preserving resize calc |
184
+ | `getAspectRatio(width, height)` | Return ratio string like "16:9" |
185
+ | `validateImage(buffer, options?)` | Validate dimensions and format |
186
+ | `processImage(buffer, sourceType, config)` | Full pipeline: strip, metadata, variants, blurhash |
187
+
188
+ ### `@xenterprises/fastify-ximagepipeline/s3`
189
+
190
+ | Function | Description |
191
+ |----------|-------------|
192
+ | `initializeS3Client(config)` | Create S3Client for R2/AWS |
193
+ | `uploadToS3(client, bucket, key, buffer, options?)` | Upload with metadata + cache headers |
194
+ | `downloadFromS3(client, bucket, key)` | Download buffer |
195
+ | `deleteFromS3(client, bucket, key)` | Delete single object |
196
+ | `listFromS3(client, bucket, prefix)` | List objects by prefix |
197
+ | `getSignedUrlForS3(client, bucket, key, expiresIn?)` | Generate signed URL (default 1h) |
198
+ | `getPublicUrl(r2Config, key)` | Generate public URL |
199
+ | `batchDeleteFromS3(client, bucket, prefix)` | Delete up to 1000 objects by prefix |
200
+
201
+ ## Environment Variables
202
+
203
+ | Variable | Required | Description |
204
+ |----------|----------|-------------|
205
+ | `R2_ENDPOINT` | Yes | R2/S3-compatible endpoint URL |
206
+ | `R2_ACCESS_KEY_ID` | Yes | Storage access key |
207
+ | `R2_SECRET_ACCESS_KEY` | Yes | Storage secret key |
208
+ | `R2_BUCKET` | Yes | Storage bucket name |
209
+ | `DATABASE_URL` | Yes | Prisma database connection string |
210
+
211
+ ## Error Reference
212
+
213
+ All errors are prefixed with `[xImagePipeline]`.
214
+
215
+ | Error | When |
216
+ |-------|------|
217
+ | `R2 configuration is required` | Missing `r2` option |
218
+ | `Database instance (Prisma client) is required` | Missing `db` option |
219
+ | `R2 configuration must include: endpoint, accessKeyId, secretAccessKey, bucket` | Incomplete R2 config |
220
+ | `No file provided` | Upload request has no file |
221
+ | `File type {mime} not allowed` | Upload MIME type not in allowedMimeTypes |
222
+ | `sourceType and sourceId are required` | Missing form fields on upload |
223
+ | `Unknown sourceType: {type}` | sourceType not in variant presets |
224
+ | `File too large. Maximum size: {n}MB` | File exceeds maxFileSize |
225
+ | `Failed to upload file to storage` | R2 upload error |
226
+ | `Failed to create processing job` | Database error during job creation |
227
+ | `Media not found: {id}` | deleteMedia called with invalid ID |
228
+
229
+ ## Database Schema
230
+
231
+ The plugin requires two Prisma models. See `SCHEMA.prisma` in the package for the full schema.
232
+
233
+ **MediaQueue** — job queue for async processing (PENDING → PROCESSING → COMPLETE/REJECTED/FAILED)
234
+
235
+ **Media** — processed media records with variant URLs, dimensions, blurhash, and metadata
236
+
237
+ ## How It Works
238
+
239
+ 1. **Upload**: Client sends multipart POST with image + sourceType + sourceId. The file is validated (type, size) and uploaded to R2 staging. A `MediaQueue` job is created with PENDING status.
240
+
241
+ 2. **Processing**: A background worker polls for PENDING jobs using pessimistic locking (prevents duplicate processing). For each job:
242
+ - Downloads from staging
243
+ - Strips EXIF metadata (preserves orientation)
244
+ - Extracts dimensions and format
245
+ - Runs content moderation (if configured)
246
+ - Generates WebP variants per sourceType config
247
+ - Generates blurhash placeholder
248
+ - Uploads variants (and optional original) to R2
249
+ - Creates `Media` record with URLs and metadata
250
+ - Marks job COMPLETE and cleans up staging
251
+
252
+ 3. **Retrieval**: Client polls the status endpoint. On COMPLETE, the response includes all variant URLs, blurhash, and dimensions for immediate frontend use.
253
+
254
+ 4. **Retry**: Failed jobs are retried up to `maxAttempts`. Stale locks (worker crashed) are automatically recovered after `lockTimeout`.
485
255
 
486
256
  ## License
487
257
 
488
- ISC
258
+ UNLICENSED