@xenterprises/fastify-ximagepipeline 1.0.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.
@@ -0,0 +1,554 @@
1
+ # xMedia Integration Guide
2
+
3
+ Quick reference for integrating xMedia into your Fastify application.
4
+
5
+ ## Step 1: Install Package
6
+
7
+ ```bash
8
+ npm install @xenterprises/fastify-xmedia @fastify/multipart
9
+ ```
10
+
11
+ ## Step 2: Set Environment Variables
12
+
13
+ ```bash
14
+ # .env
15
+ R2_ENDPOINT=https://[account-id].r2.cloudflarestorage.com
16
+ R2_ACCESS_KEY_ID=your_access_key
17
+ R2_SECRET_ACCESS_KEY=your_secret_key
18
+ R2_BUCKET=your-bucket-name
19
+
20
+ DATABASE_URL=postgresql://user:password@localhost:5432/dbname
21
+
22
+ # Optional
23
+ MODERATION_PROVIDER=rekognition
24
+ MODERATION_API_KEY=your_api_key
25
+ ```
26
+
27
+ ## Step 3: Update Prisma Schema
28
+
29
+ Add this to your `schema.prisma`:
30
+
31
+ ```prisma
32
+ enum MediaStatus {
33
+ PENDING
34
+ PROCESSING
35
+ COMPLETE
36
+ REJECTED
37
+ FAILED
38
+ }
39
+
40
+ enum ModerationResult {
41
+ APPROVED
42
+ REJECTED
43
+ FLAGGED
44
+ }
45
+
46
+ model MediaQueue {
47
+ id String @id @default(cuid())
48
+ status MediaStatus @default(PENDING)
49
+ sourceType String
50
+ sourceId String
51
+ stagingKey String
52
+ originalFilename String
53
+ mimeType String
54
+ fileSize Int
55
+ mediaId String?
56
+ media Media? @relation(fields: [mediaId], references: [id], onDelete: SetNull)
57
+ attempts Int @default(0)
58
+ maxAttempts Int @default(3)
59
+ errorMsg String?
60
+ moderationResult ModerationResult?
61
+ moderationDetails Json?
62
+ lockedAt DateTime?
63
+ lockedBy String?
64
+ createdAt DateTime @default(now())
65
+ updatedAt DateTime @updatedAt
66
+
67
+ @@index([status, createdAt])
68
+ @@index([sourceType, sourceId])
69
+ @@index([lockedAt])
70
+ }
71
+
72
+ model Media {
73
+ id String @id @default(cuid())
74
+ urls Json @default("{}")
75
+ originalUrl String
76
+ width Int
77
+ height Int
78
+ format String
79
+ aspectRatio String
80
+ blurhash String
81
+ focalPoint Json @default("{\"x\": 0.5, \"y\": 0.5}")
82
+ sourceType String
83
+ sourceId String
84
+ originalFilename String
85
+ mimeType String
86
+ fileSize Int
87
+ exifStripped Boolean @default(true)
88
+ createdAt DateTime @default(now())
89
+ updatedAt DateTime @updatedAt
90
+ queue MediaQueue[]
91
+
92
+ @@index([sourceType, sourceId])
93
+ @@index([createdAt])
94
+ }
95
+ ```
96
+
97
+ Then migrate:
98
+ ```bash
99
+ npx prisma migrate dev --name add_media_tables
100
+ ```
101
+
102
+ ## Step 4: Register Plugin in Fastify
103
+
104
+ ```javascript
105
+ // server.js or main app file
106
+ import Fastify from 'fastify';
107
+ import multipart from '@fastify/multipart';
108
+ import xMedia from '@xenterprises/fastify-xmedia';
109
+ import { PrismaClient } from '@prisma/client';
110
+
111
+ const fastify = Fastify();
112
+ const prisma = new PrismaClient();
113
+
114
+ // Register multipart first (required for file uploads)
115
+ await fastify.register(multipart);
116
+
117
+ // Register xMedia plugin
118
+ await fastify.register(xMedia, {
119
+ // Required configuration
120
+ r2: {
121
+ endpoint: process.env.R2_ENDPOINT,
122
+ region: 'auto',
123
+ accessKeyId: process.env.R2_ACCESS_KEY_ID,
124
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
125
+ bucket: process.env.R2_BUCKET,
126
+ },
127
+
128
+ db: prisma,
129
+
130
+ // Optional: Content moderation
131
+ moderation: process.env.MODERATION_PROVIDER ? {
132
+ provider: process.env.MODERATION_PROVIDER,
133
+ apiKey: process.env.MODERATION_API_KEY,
134
+ } : null,
135
+
136
+ // Optional: Worker configuration
137
+ worker: {
138
+ enabled: true, // Process jobs in background
139
+ pollInterval: 5000, // Check for new jobs every 5 seconds
140
+ maxAttempts: 3, // Retry failed jobs 3 times
141
+ lockTimeout: 5 * 60 * 1000, // Release locks after 5 minutes
142
+ },
143
+ });
144
+
145
+ await fastify.listen({ port: 3000 });
146
+ ```
147
+
148
+ ## Step 5: Create Frontend Upload Form
149
+
150
+ ### React Example
151
+
152
+ ```jsx
153
+ import { useState } from 'react';
154
+
155
+ export function AvatarUpload({ userId, onComplete }) {
156
+ const [jobId, setJobId] = useState(null);
157
+ const [status, setStatus] = useState(null);
158
+ const [loading, setLoading] = useState(false);
159
+ const [error, setError] = useState(null);
160
+
161
+ const handleUpload = async (file) => {
162
+ setLoading(true);
163
+ const formData = new FormData();
164
+ formData.append('file', file);
165
+ formData.append('sourceType', 'avatar');
166
+ formData.append('sourceId', userId);
167
+
168
+ try {
169
+ const response = await fetch('/media/upload', {
170
+ method: 'POST',
171
+ body: formData,
172
+ });
173
+
174
+ const data = await response.json();
175
+ setJobId(data.jobId);
176
+ pollStatus(data.jobId);
177
+ } catch (err) {
178
+ setError(err.message);
179
+ setLoading(false);
180
+ }
181
+ };
182
+
183
+ const pollStatus = async (id) => {
184
+ const maxAttempts = 120; // 2 minutes with 1s intervals
185
+ let attempts = 0;
186
+
187
+ const interval = setInterval(async () => {
188
+ attempts++;
189
+
190
+ try {
191
+ const response = await fetch(`/media/status/${id}`);
192
+ const data = await response.json();
193
+ setStatus(data);
194
+
195
+ if (data.status === 'COMPLETE') {
196
+ setLoading(false);
197
+ onComplete?.(data.media);
198
+ clearInterval(interval);
199
+ } else if (data.status === 'REJECTED') {
200
+ setError(`Image rejected: ${data.reason}`);
201
+ setLoading(false);
202
+ clearInterval(interval);
203
+ } else if (data.status === 'FAILED') {
204
+ setError(`Processing failed: ${data.error}`);
205
+ setLoading(false);
206
+ clearInterval(interval);
207
+ } else if (attempts >= maxAttempts) {
208
+ setError('Processing timeout');
209
+ setLoading(false);
210
+ clearInterval(interval);
211
+ }
212
+ } catch (err) {
213
+ console.error('Status check error:', err);
214
+ }
215
+ }, 1000);
216
+ };
217
+
218
+ return (
219
+ <div>
220
+ <input
221
+ type="file"
222
+ accept="image/*"
223
+ onChange={(e) => handleUpload(e.target.files[0])}
224
+ disabled={loading}
225
+ />
226
+
227
+ {loading && <p>Processing...</p>}
228
+ {error && <p style={{ color: 'red' }}>{error}</p>}
229
+
230
+ {status?.media && (
231
+ <div>
232
+ <img src={status.media.urls.sm} alt="Avatar" width={200} />
233
+ <p>Upload complete!</p>
234
+ </div>
235
+ )}
236
+ </div>
237
+ );
238
+ }
239
+ ```
240
+
241
+ ### Nuxt Example
242
+
243
+ ```vue
244
+ <template>
245
+ <div class="upload-container">
246
+ <input
247
+ ref="fileInput"
248
+ type="file"
249
+ accept="image/*"
250
+ @change="handleFileSelect"
251
+ :disabled="loading"
252
+ />
253
+
254
+ <div v-if="loading" class="loading">
255
+ Processing image...
256
+ </div>
257
+
258
+ <div v-if="error" class="error">
259
+ {{ error }}
260
+ </div>
261
+
262
+ <div v-if="media" class="complete">
263
+ <img :src="media.urls.sm" :alt="media.originalFilename" />
264
+ <p>Upload complete!</p>
265
+ </div>
266
+ </div>
267
+ </template>
268
+
269
+ <script setup>
270
+ import { ref } from 'vue';
271
+
272
+ const props = defineProps({
273
+ sourceType: { type: String, default: 'avatar' },
274
+ sourceId: { type: String, required: true },
275
+ });
276
+
277
+ const emit = defineEmits(['complete']);
278
+
279
+ const fileInput = ref(null);
280
+ const jobId = ref(null);
281
+ const status = ref(null);
282
+ const media = ref(null);
283
+ const loading = ref(false);
284
+ const error = ref(null);
285
+
286
+ const handleFileSelect = async (event) => {
287
+ const file = event.target.files?.[0];
288
+ if (!file) return;
289
+
290
+ loading.value = true;
291
+ error.value = null;
292
+
293
+ const formData = new FormData();
294
+ formData.append('file', file);
295
+ formData.append('sourceType', props.sourceType);
296
+ formData.append('sourceId', props.sourceId);
297
+
298
+ try {
299
+ const response = await fetch('/media/upload', {
300
+ method: 'POST',
301
+ body: formData,
302
+ });
303
+
304
+ const data = await response.json();
305
+ jobId.value = data.jobId;
306
+ pollStatus();
307
+ } catch (err) {
308
+ error.value = `Upload failed: ${err.message}`;
309
+ loading.value = false;
310
+ }
311
+ };
312
+
313
+ const pollStatus = async () => {
314
+ const maxAttempts = 120;
315
+ let attempts = 0;
316
+
317
+ const interval = setInterval(async () => {
318
+ attempts++;
319
+
320
+ try {
321
+ const response = await fetch(`/media/status/${jobId.value}`);
322
+ const data = await response.json();
323
+ status.value = data;
324
+
325
+ if (data.status === 'COMPLETE') {
326
+ media.value = data.media;
327
+ loading.value = false;
328
+ emit('complete', data.media);
329
+ clearInterval(interval);
330
+ } else if (data.status === 'REJECTED') {
331
+ error.value = `Image rejected: ${data.reason}`;
332
+ loading.value = false;
333
+ clearInterval(interval);
334
+ } else if (data.status === 'FAILED') {
335
+ error.value = `Processing failed: ${data.error}`;
336
+ loading.value = false;
337
+ clearInterval(interval);
338
+ } else if (attempts >= maxAttempts) {
339
+ error.value = 'Processing timeout';
340
+ loading.value = false;
341
+ clearInterval(interval);
342
+ }
343
+ } catch (err) {
344
+ console.error('Status check error:', err);
345
+ }
346
+ }, 1000);
347
+ };
348
+ </script>
349
+
350
+ <style scoped>
351
+ .upload-container {
352
+ display: flex;
353
+ flex-direction: column;
354
+ gap: 1rem;
355
+ }
356
+
357
+ .loading {
358
+ color: #0066cc;
359
+ }
360
+
361
+ .error {
362
+ color: #cc0000;
363
+ }
364
+
365
+ .complete {
366
+ color: #00aa00;
367
+ }
368
+
369
+ .complete img {
370
+ max-width: 200px;
371
+ border-radius: 8px;
372
+ margin: 1rem 0;
373
+ }
374
+ </style>
375
+ ```
376
+
377
+ ## Step 6: Usage Examples
378
+
379
+ ### Upload an Avatar
380
+
381
+ ```bash
382
+ curl -X POST \
383
+ -F "file=@avatar.jpg" \
384
+ -F "sourceType=avatar" \
385
+ -F "sourceId=user-123" \
386
+ http://localhost:3000/media/upload
387
+ ```
388
+
389
+ Response:
390
+ ```json
391
+ {
392
+ "jobId": "clh7k9w1j000...",
393
+ "message": "File uploaded. Processing started.",
394
+ "statusUrl": "/media/status/clh7k9w1j000..."
395
+ }
396
+ ```
397
+
398
+ ### Check Processing Status
399
+
400
+ ```bash
401
+ curl http://localhost:3000/media/status/clh7k9w1j000...
402
+ ```
403
+
404
+ When complete:
405
+ ```json
406
+ {
407
+ "jobId": "clh7k9w1j000...",
408
+ "status": "COMPLETE",
409
+ "sourceType": "avatar",
410
+ "sourceId": "user-123",
411
+ "media": {
412
+ "id": "media-...",
413
+ "urls": {
414
+ "xs": "https://bucket.r2.dev/media/avatar/user-123/.../xs.webp",
415
+ "sm": "https://bucket.r2.dev/media/avatar/user-123/.../sm.webp"
416
+ },
417
+ "originalUrl": "https://bucket.r2.dev/originals/avatar/user-123/.../original.jpg",
418
+ "width": 1920,
419
+ "height": 1920,
420
+ "aspectRatio": "1:1",
421
+ "blurhash": "UeKUpMx..."
422
+ }
423
+ }
424
+ ```
425
+
426
+ ## Step 7: R2 Setup
427
+
428
+ ### Create R2 Bucket
429
+
430
+ 1. Go to Cloudflare dashboard
431
+ 2. Navigate to R2
432
+ 3. Create new bucket
433
+ 4. Note the endpoint URL
434
+
435
+ ### Set CORS Policy
436
+
437
+ In R2 bucket settings:
438
+ ```json
439
+ {
440
+ "CORSRules": [
441
+ {
442
+ "AllowedOrigins": ["https://yourdomain.com"],
443
+ "AllowedMethods": ["GET"],
444
+ "AllowedHeaders": ["*"],
445
+ "MaxAgeSeconds": 3600
446
+ }
447
+ ]
448
+ }
449
+ ```
450
+
451
+ ### Set Lifecycle Policy
452
+
453
+ In R2 bucket settings:
454
+ - Delete objects in `/staging/` older than 1 day
455
+ - Keep `/media/` and `/originals/` permanently
456
+
457
+ ## Step 8: Optional - Connect to User Model
458
+
459
+ Add to your User Prisma model:
460
+
461
+ ```prisma
462
+ model User {
463
+ id String @id @default(cuid())
464
+ // ... other fields
465
+
466
+ // Avatar relationship
467
+ avatar Media? @relation(fields: [avatarId], references: [id], onDelete: SetNull)
468
+ avatarId String?
469
+ }
470
+ ```
471
+
472
+ Then update avatar:
473
+
474
+ ```javascript
475
+ // After upload completes
476
+ await prisma.user.update({
477
+ where: { id: userId },
478
+ data: { avatarId: media.id },
479
+ });
480
+ ```
481
+
482
+ ## Step 9: Display Images
483
+
484
+ ```jsx
485
+ // In your React/Nuxt components
486
+ function UserProfile({ user }) {
487
+ const avatar = user.avatar;
488
+
489
+ if (!avatar) return <DefaultAvatar />;
490
+
491
+ return (
492
+ <img
493
+ src={avatar.urls.sm} // Mobile
494
+ srcSet={`
495
+ ${avatar.urls.xs} 80w,
496
+ ${avatar.urls.sm} 200w
497
+ `}
498
+ alt={user.name}
499
+ style={{ borderRadius: '50%', width: 200 }}
500
+ />
501
+ );
502
+ }
503
+ ```
504
+
505
+ ## Step 10: Error Handling
506
+
507
+ Common issues and solutions:
508
+
509
+ ### R2 Upload Fails
510
+ - ✅ Verify credentials
511
+ - ✅ Check bucket name
512
+ - ✅ Ensure endpoint URL is correct
513
+ - ✅ Check CORS policy
514
+
515
+ ### Jobs Stuck in PROCESSING
516
+ - ✅ Jobs auto-recover after 5 minutes
517
+ - ✅ Check worker logs
518
+ - ✅ Verify database connectivity
519
+
520
+ ### Images Rejected
521
+ - ✅ Check moderation settings
522
+ - ✅ Review moderation flags in job details
523
+ - ✅ Adjust moderation thresholds
524
+
525
+ ## Troubleshooting Checklist
526
+
527
+ - [ ] Prisma models migrated
528
+ - [ ] Environment variables set
529
+ - [ ] R2 bucket created and configured
530
+ - [ ] CORS policy set on R2
531
+ - [ ] Lifecycle policy set on R2
532
+ - [ ] Plugin registered in correct order
533
+ - [ ] `@fastify/multipart` registered before xMedia
534
+ - [ ] Database connection working
535
+ - [ ] Worker is running (check logs)
536
+
537
+ ## Support
538
+
539
+ For issues or questions:
540
+ - Check `README.md` for detailed documentation
541
+ - Review `IMPLEMENTATION_SUMMARY.md` for architecture
542
+ - Check worker logs: `console.info()` statements
543
+ - Inspect `MediaQueue` table for job details
544
+
545
+ ## Next Steps
546
+
547
+ 1. ✅ Upload test image
548
+ 2. ✅ Verify processing completes
549
+ 3. ✅ Display images in UI
550
+ 4. ✅ Add moderation (optional)
551
+ 5. ✅ Set up monitoring
552
+ 6. ✅ Configure CDN cache
553
+
554
+ Happy image uploading! 🎉