@spfn/core 0.2.0-beta.5 → 0.2.0-beta.8

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,717 @@
1
+ # File Upload
2
+
3
+ Complete guide for handling file uploads in SPFN applications.
4
+
5
+ ## Basic Usage
6
+
7
+ ### Single File Upload
8
+
9
+ ```typescript
10
+ import { route, FileSchema } from '@spfn/core/route';
11
+ import { Type } from '@sinclair/typebox';
12
+
13
+ export const uploadAvatar = route.post('/users/:id/avatar')
14
+ .input({
15
+ params: Type.Object({ id: Type.String() }),
16
+ formData: Type.Object({
17
+ file: FileSchema(),
18
+ description: Type.Optional(Type.String())
19
+ })
20
+ })
21
+ .handler(async (c) =>
22
+ {
23
+ const { params, formData } = await c.data();
24
+ const file = formData.file as File;
25
+
26
+ // File properties
27
+ console.log(file.name); // original filename
28
+ console.log(file.size); // size in bytes
29
+ console.log(file.type); // MIME type
30
+
31
+ // Read file content
32
+ const buffer = await file.arrayBuffer();
33
+ const text = await file.text(); // for text files
34
+
35
+ return c.created({ filename: file.name, size: file.size });
36
+ });
37
+ ```
38
+
39
+ ### Multiple Files
40
+
41
+ ```typescript
42
+ import { route, FileArraySchema } from '@spfn/core/route';
43
+
44
+ export const uploadDocuments = route.post('/documents')
45
+ .input({
46
+ formData: Type.Object({
47
+ files: FileArraySchema(),
48
+ category: Type.String()
49
+ })
50
+ })
51
+ .handler(async (c) =>
52
+ {
53
+ const { formData } = await c.data();
54
+ const files = formData.files as File[];
55
+
56
+ const results = await Promise.all(
57
+ files.map(async (file) =>
58
+ {
59
+ const buffer = await file.arrayBuffer();
60
+ // Process each file...
61
+ return { name: file.name, size: file.size };
62
+ })
63
+ );
64
+
65
+ return { uploaded: results.length, files: results };
66
+ });
67
+ ```
68
+
69
+ ### Mixed Fields
70
+
71
+ ```typescript
72
+ export const createPost = route.post('/posts')
73
+ .input({
74
+ formData: Type.Object({
75
+ title: Type.String(),
76
+ content: Type.String(),
77
+ image: OptionalFileSchema(),
78
+ tags: Type.Optional(Type.String()) // JSON string
79
+ })
80
+ })
81
+ .handler(async (c) =>
82
+ {
83
+ const { formData } = await c.data();
84
+ const image = formData.image as File | undefined;
85
+
86
+ const post = await postRepo.create({
87
+ title: formData.title,
88
+ content: formData.content,
89
+ tags: formData.tags ? JSON.parse(formData.tags) : [],
90
+ imageUrl: image ? await saveFile(image) : null
91
+ });
92
+
93
+ return c.created(post);
94
+ });
95
+ ```
96
+
97
+ ---
98
+
99
+ ## Validation
100
+
101
+ ### Declarative Validation (Recommended)
102
+
103
+ Use schema options for automatic validation:
104
+
105
+ ```typescript
106
+ import { route, FileSchema, FileArraySchema } from '@spfn/core/route';
107
+ import { Type } from '@sinclair/typebox';
108
+
109
+ // Single file with size and type constraints
110
+ export const uploadAvatar = route.post('/avatars')
111
+ .input({
112
+ formData: Type.Object({
113
+ avatar: FileSchema({
114
+ maxSize: 5 * 1024 * 1024, // 5MB
115
+ allowedTypes: ['image/jpeg', 'image/png', 'image/webp']
116
+ })
117
+ })
118
+ })
119
+ .handler(async (c) =>
120
+ {
121
+ const { formData } = await c.data();
122
+ const file = formData.avatar as File;
123
+ // File is already validated - safe to use
124
+ return { name: file.name, size: file.size };
125
+ });
126
+
127
+ // Multiple files with count and size limits
128
+ export const uploadDocuments = route.post('/documents')
129
+ .input({
130
+ formData: Type.Object({
131
+ files: FileArraySchema({
132
+ maxFiles: 5,
133
+ minFiles: 1,
134
+ maxSize: 10 * 1024 * 1024, // 10MB per file
135
+ allowedTypes: ['application/pdf', 'application/msword']
136
+ })
137
+ })
138
+ })
139
+ .handler(async (c) =>
140
+ {
141
+ const { formData } = await c.data();
142
+ const files = formData.files as File[];
143
+ return { count: files.length };
144
+ });
145
+ ```
146
+
147
+ **Available Options:**
148
+
149
+ | Option | Type | Description |
150
+ |--------|------|-------------|
151
+ | `maxSize` | number | Maximum file size in bytes |
152
+ | `minSize` | number | Minimum file size in bytes |
153
+ | `allowedTypes` | string[] | Allowed MIME types |
154
+ | `maxFiles` | number | Maximum file count (FileArraySchema only) |
155
+ | `minFiles` | number | Minimum file count (FileArraySchema only) |
156
+
157
+ Validation errors are thrown automatically with 400 status code and structured error response:
158
+
159
+ ```json
160
+ {
161
+ "message": "Invalid form data",
162
+ "fields": [
163
+ {
164
+ "path": "/avatar",
165
+ "message": "File size 15.0MB exceeds maximum 5.0MB",
166
+ "value": 15728640
167
+ }
168
+ ]
169
+ }
170
+ ```
171
+
172
+ ---
173
+
174
+ ### Manual Validation (Advanced)
175
+
176
+ For custom validation logic, validate in the handler:
177
+
178
+ ```typescript
179
+ import { ValidationError } from '@spfn/core/errors';
180
+
181
+ const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
182
+
183
+ export const uploadImage = route.post('/images')
184
+ .input({
185
+ formData: Type.Object({
186
+ image: FileSchema()
187
+ })
188
+ })
189
+ .handler(async (c) =>
190
+ {
191
+ const { formData } = await c.data();
192
+ const file = formData.image as File;
193
+
194
+ // Custom validation logic
195
+ if (!ALLOWED_IMAGE_TYPES.includes(file.type))
196
+ {
197
+ throw new ValidationError({
198
+ message: 'Invalid file type',
199
+ fields: [{
200
+ path: '/image',
201
+ message: `Allowed types: ${ALLOWED_IMAGE_TYPES.join(', ')}`,
202
+ value: file.type
203
+ }]
204
+ });
205
+ }
206
+
207
+ // Process valid image...
208
+ });
209
+ ```
210
+
211
+ ### File Size Validation
212
+
213
+ ```typescript
214
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
215
+
216
+ export const uploadFile = route.post('/files')
217
+ .input({
218
+ formData: Type.Object({
219
+ file: FileSchema()
220
+ })
221
+ })
222
+ .handler(async (c) =>
223
+ {
224
+ const { formData } = await c.data();
225
+ const file = formData.file as File;
226
+
227
+ if (file.size > MAX_FILE_SIZE)
228
+ {
229
+ throw new ValidationError({
230
+ message: 'File too large',
231
+ fields: [{
232
+ path: '/file',
233
+ message: `Maximum size: ${MAX_FILE_SIZE / 1024 / 1024}MB`,
234
+ value: file.size
235
+ }]
236
+ });
237
+ }
238
+
239
+ // Process file...
240
+ });
241
+ ```
242
+
243
+ ### Reusable Validation Helper
244
+
245
+ ```typescript
246
+ // lib/file-validation.ts
247
+ import { ValidationError } from '@spfn/core/errors';
248
+
249
+ interface FileValidationOptions
250
+ {
251
+ maxSize?: number;
252
+ allowedTypes?: string[];
253
+ required?: boolean;
254
+ }
255
+
256
+ export function validateFile(
257
+ file: File | undefined,
258
+ fieldName: string,
259
+ options: FileValidationOptions = {}
260
+ ): void
261
+ {
262
+ const { maxSize, allowedTypes, required = true } = options;
263
+
264
+ if (!file)
265
+ {
266
+ if (required)
267
+ {
268
+ throw new ValidationError({
269
+ message: 'File required',
270
+ fields: [{ path: `/${fieldName}`, message: 'File is required', value: null }]
271
+ });
272
+ }
273
+ return;
274
+ }
275
+
276
+ if (maxSize && file.size > maxSize)
277
+ {
278
+ throw new ValidationError({
279
+ message: 'File too large',
280
+ fields: [{
281
+ path: `/${fieldName}`,
282
+ message: `Maximum size: ${(maxSize / 1024 / 1024).toFixed(1)}MB`,
283
+ value: file.size
284
+ }]
285
+ });
286
+ }
287
+
288
+ if (allowedTypes && !allowedTypes.includes(file.type))
289
+ {
290
+ throw new ValidationError({
291
+ message: 'Invalid file type',
292
+ fields: [{
293
+ path: `/${fieldName}`,
294
+ message: `Allowed types: ${allowedTypes.join(', ')}`,
295
+ value: file.type
296
+ }]
297
+ });
298
+ }
299
+ }
300
+
301
+ // Usage
302
+ export const uploadImage = route.post('/images')
303
+ .input({ formData: Type.Object({ image: FileSchema() }) })
304
+ .handler(async (c) =>
305
+ {
306
+ const { formData } = await c.data();
307
+ const file = formData.image as File;
308
+
309
+ validateFile(file, 'image', {
310
+ maxSize: 5 * 1024 * 1024,
311
+ allowedTypes: ['image/jpeg', 'image/png', 'image/webp']
312
+ });
313
+
314
+ // File is valid...
315
+ });
316
+ ```
317
+
318
+ ---
319
+
320
+ ## Storage Patterns
321
+
322
+ ### Local File System
323
+
324
+ ```typescript
325
+ import { writeFile, mkdir } from 'fs/promises';
326
+ import { join } from 'path';
327
+ import { randomUUID } from 'crypto';
328
+
329
+ const UPLOAD_DIR = './uploads';
330
+
331
+ async function saveToLocal(file: File, subdir: string = ''): Promise<string>
332
+ {
333
+ const dir = join(UPLOAD_DIR, subdir);
334
+ await mkdir(dir, { recursive: true });
335
+
336
+ const ext = file.name.split('.').pop() || '';
337
+ const filename = `${randomUUID()}.${ext}`;
338
+ const filepath = join(dir, filename);
339
+
340
+ const buffer = Buffer.from(await file.arrayBuffer());
341
+ await writeFile(filepath, buffer);
342
+
343
+ return filepath;
344
+ }
345
+
346
+ export const uploadFile = route.post('/files')
347
+ .input({ formData: Type.Object({ file: FileSchema() }) })
348
+ .handler(async (c) =>
349
+ {
350
+ const { formData } = await c.data();
351
+ const file = formData.file as File;
352
+
353
+ const path = await saveToLocal(file, 'documents');
354
+
355
+ return c.created({ path, originalName: file.name });
356
+ });
357
+ ```
358
+
359
+ ### AWS S3
360
+
361
+ ```typescript
362
+ import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
363
+ import { randomUUID } from 'crypto';
364
+
365
+ const s3 = new S3Client({ region: process.env.AWS_REGION });
366
+ const BUCKET = process.env.S3_BUCKET!;
367
+
368
+ async function uploadToS3(file: File, prefix: string = ''): Promise<string>
369
+ {
370
+ const ext = file.name.split('.').pop() || '';
371
+ const key = `${prefix}${randomUUID()}.${ext}`;
372
+
373
+ await s3.send(new PutObjectCommand({
374
+ Bucket: BUCKET,
375
+ Key: key,
376
+ Body: Buffer.from(await file.arrayBuffer()),
377
+ ContentType: file.type,
378
+ Metadata: {
379
+ originalName: file.name
380
+ }
381
+ }));
382
+
383
+ return `https://${BUCKET}.s3.amazonaws.com/${key}`;
384
+ }
385
+
386
+ export const uploadAvatar = route.post('/avatars')
387
+ .input({
388
+ formData: Type.Object({
389
+ image: FileSchema({
390
+ maxSize: 2 * 1024 * 1024,
391
+ allowedTypes: ['image/jpeg', 'image/png']
392
+ })
393
+ })
394
+ })
395
+ .handler(async (c) =>
396
+ {
397
+ const { formData } = await c.data();
398
+ const file = formData.image as File;
399
+ // File already validated via schema
400
+
401
+ const url = await uploadToS3(file, 'avatars/');
402
+
403
+ return c.created({ url });
404
+ });
405
+ ```
406
+
407
+ ### Cloudflare R2
408
+
409
+ ```typescript
410
+ import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
411
+
412
+ const r2 = new S3Client({
413
+ region: 'auto',
414
+ endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
415
+ credentials: {
416
+ accessKeyId: process.env.R2_ACCESS_KEY_ID!,
417
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!
418
+ }
419
+ });
420
+
421
+ async function uploadToR2(file: File, prefix: string = ''): Promise<string>
422
+ {
423
+ const key = `${prefix}${randomUUID()}.${file.name.split('.').pop()}`;
424
+
425
+ await r2.send(new PutObjectCommand({
426
+ Bucket: process.env.R2_BUCKET,
427
+ Key: key,
428
+ Body: Buffer.from(await file.arrayBuffer()),
429
+ ContentType: file.type
430
+ }));
431
+
432
+ return `${process.env.R2_PUBLIC_URL}/${key}`;
433
+ }
434
+ ```
435
+
436
+ ---
437
+
438
+ ## Streaming (Large Files)
439
+
440
+ For large files, use streaming to avoid memory issues:
441
+
442
+ ```typescript
443
+ import { Readable } from 'stream';
444
+ import { createWriteStream } from 'fs';
445
+ import { pipeline } from 'stream/promises';
446
+
447
+ export const uploadLargeFile = route.post('/large-files')
448
+ .handler(async (c) =>
449
+ {
450
+ // Access raw request for streaming
451
+ const formData = await c.raw.req.formData();
452
+ const file = formData.get('file') as File;
453
+
454
+ if (!file)
455
+ {
456
+ throw new ValidationError({ message: 'File required' });
457
+ }
458
+
459
+ // Stream to disk
460
+ const outputPath = `./uploads/${randomUUID()}.bin`;
461
+ const writeStream = createWriteStream(outputPath);
462
+
463
+ // Convert File to Node.js Readable stream
464
+ const reader = file.stream().getReader();
465
+ const nodeStream = new Readable({
466
+ async read()
467
+ {
468
+ const { done, value } = await reader.read();
469
+ if (done)
470
+ {
471
+ this.push(null);
472
+ }
473
+ else
474
+ {
475
+ this.push(Buffer.from(value));
476
+ }
477
+ }
478
+ });
479
+
480
+ await pipeline(nodeStream, writeStream);
481
+
482
+ return c.created({ path: outputPath, size: file.size });
483
+ });
484
+ ```
485
+
486
+ ---
487
+
488
+ ## Security Best Practices
489
+
490
+ ### 1. Always Validate MIME Types
491
+
492
+ ```typescript
493
+ // Don't trust the file extension - check MIME type
494
+ const file = formData.file as File;
495
+
496
+ // Also consider using magic bytes for true type detection
497
+ import { fileTypeFromBuffer } from 'file-type';
498
+
499
+ const buffer = Buffer.from(await file.arrayBuffer());
500
+ const detected = await fileTypeFromBuffer(buffer);
501
+
502
+ if (!detected || !ALLOWED_TYPES.includes(detected.mime))
503
+ {
504
+ throw new ValidationError({ message: 'Invalid file type' });
505
+ }
506
+ ```
507
+
508
+ ### 2. Generate New Filenames
509
+
510
+ ```typescript
511
+ // Never use user-provided filenames directly
512
+ const userFilename = file.name; // potentially malicious
513
+
514
+ // Generate safe filename
515
+ const safeFilename = `${randomUUID()}.${getExtension(file.type)}`;
516
+
517
+ function getExtension(mimeType: string): string
518
+ {
519
+ const map: Record<string, string> = {
520
+ 'image/jpeg': 'jpg',
521
+ 'image/png': 'png',
522
+ 'image/webp': 'webp',
523
+ 'application/pdf': 'pdf'
524
+ };
525
+ return map[mimeType] || 'bin';
526
+ }
527
+ ```
528
+
529
+ ### 3. Limit File Size at Server Level
530
+
531
+ ```typescript
532
+ // server.config.ts
533
+ export default defineServerConfig()
534
+ .lifecycle({
535
+ beforeRoutes: async (app) =>
536
+ {
537
+ // Global body size limit (Hono middleware)
538
+ app.use('*', async (c, next) =>
539
+ {
540
+ const contentLength = parseInt(c.req.header('content-length') || '0');
541
+ const MAX_BODY_SIZE = 50 * 1024 * 1024; // 50MB
542
+
543
+ if (contentLength > MAX_BODY_SIZE)
544
+ {
545
+ return c.json({ error: 'Request too large' }, 413);
546
+ }
547
+
548
+ await next();
549
+ });
550
+ }
551
+ })
552
+ .build();
553
+ ```
554
+
555
+ ### 4. Store Outside Web Root
556
+
557
+ ```typescript
558
+ // Files should not be directly accessible via URL
559
+ const UPLOAD_DIR = '/var/data/uploads'; // Outside public/
560
+
561
+ // Serve files through authenticated route
562
+ export const getFile = route.get('/files/:id')
563
+ .use([authMiddleware])
564
+ .handler(async (c) =>
565
+ {
566
+ const { params } = await c.data();
567
+ const file = await fileRepo.findById(params.id);
568
+
569
+ if (!file || !canAccess(c.raw.get('user'), file))
570
+ {
571
+ throw new NotFoundError();
572
+ }
573
+
574
+ // Stream file from secure location
575
+ const buffer = await readFile(file.path);
576
+ return new Response(buffer, {
577
+ headers: {
578
+ 'Content-Type': file.mimeType,
579
+ 'Content-Disposition': `attachment; filename="${file.originalName}"`
580
+ }
581
+ });
582
+ });
583
+ ```
584
+
585
+ ### 5. Scan for Malware (Production)
586
+
587
+ ```typescript
588
+ import { ClamScan } from 'clamscan';
589
+
590
+ const clam = new ClamScan({ clamdscan: { host: 'localhost', port: 3310 } });
591
+
592
+ async function scanFile(buffer: Buffer): Promise<boolean>
593
+ {
594
+ const { isInfected } = await clam.scanBuffer(buffer);
595
+ return !isInfected;
596
+ }
597
+
598
+ export const uploadFile = route.post('/files')
599
+ .handler(async (c) =>
600
+ {
601
+ const { formData } = await c.data();
602
+ const file = formData.file as File;
603
+ const buffer = Buffer.from(await file.arrayBuffer());
604
+
605
+ const isSafe = await scanFile(buffer);
606
+ if (!isSafe)
607
+ {
608
+ throw new ValidationError({ message: 'File rejected by security scan' });
609
+ }
610
+
611
+ // Proceed with safe file...
612
+ });
613
+ ```
614
+
615
+ ---
616
+
617
+ ## Client Usage
618
+
619
+ ### SPFN API Client (Recommended)
620
+
621
+ Type-safe file upload with full type inference:
622
+
623
+ ```typescript
624
+ import { createApi } from '@spfn/core/nextjs';
625
+ import type { AppRouter } from '@/server/router';
626
+
627
+ const api = createApi<AppRouter>();
628
+
629
+ // Single file upload
630
+ const result = await api.uploadAvatar.call({
631
+ params: { id: '123' },
632
+ formData: {
633
+ file: fileInput.files[0], // File object - type-safe!
634
+ description: 'Profile photo' // string field
635
+ }
636
+ });
637
+
638
+ // Multiple files
639
+ const docs = await api.uploadDocuments.call({
640
+ formData: {
641
+ files: Array.from(fileInput.files), // File[]
642
+ category: 'reports'
643
+ }
644
+ });
645
+
646
+ // With additional options
647
+ const result = await api.uploadFile
648
+ .headers({ 'X-Custom': 'value' })
649
+ .call({
650
+ formData: { file: myFile }
651
+ });
652
+ ```
653
+
654
+ **How it works:**
655
+ 1. Client builds `FormData` with files and metadata
656
+ 2. RPC Proxy parses multipart and forwards to backend
657
+ 3. Backend route receives typed `formData` via `c.data()`
658
+
659
+ ### Fetch API
660
+
661
+ For direct backend calls (bypassing RPC proxy):
662
+
663
+ ```typescript
664
+ const formData = new FormData();
665
+ formData.append('file', fileInput.files[0]);
666
+ formData.append('description', 'My file');
667
+
668
+ const response = await fetch('/api/upload', {
669
+ method: 'POST',
670
+ body: formData
671
+ // Note: Don't set Content-Type header - browser sets it with boundary
672
+ });
673
+ ```
674
+
675
+ ### curl
676
+
677
+ ```bash
678
+ # Single file
679
+ curl -X POST http://localhost:3000/upload \
680
+ -F "file=@./document.pdf" \
681
+ -F "description=Important document"
682
+
683
+ # Multiple files
684
+ curl -X POST http://localhost:3000/upload-multiple \
685
+ -F "files=@./file1.txt" \
686
+ -F "files=@./file2.txt"
687
+ ```
688
+
689
+ ---
690
+
691
+ ## Summary
692
+
693
+ | Schema | Description |
694
+ |--------|-------------|
695
+ | `FileSchema()` | Single File object |
696
+ | `FileSchema(options)` | Single File with validation |
697
+ | `FileArraySchema()` | Array of File objects |
698
+ | `FileArraySchema(options)` | Array of Files with validation |
699
+ | `OptionalFileSchema()` | Optional single File |
700
+ | `OptionalFileSchema(options)` | Optional File with validation |
701
+
702
+ | Validation Option | Type | Description |
703
+ |-------------------|------|-------------|
704
+ | `maxSize` | number | Maximum file size in bytes |
705
+ | `minSize` | number | Minimum file size in bytes |
706
+ | `allowedTypes` | string[] | Allowed MIME types |
707
+ | `maxFiles` | number | Maximum file count (FileArraySchema only) |
708
+ | `minFiles` | number | Minimum file count (FileArraySchema only) |
709
+
710
+ | File Property | Type | Description |
711
+ |---------------|------|-------------|
712
+ | `file.name` | string | Original filename |
713
+ | `file.size` | number | Size in bytes |
714
+ | `file.type` | string | MIME type |
715
+ | `file.arrayBuffer()` | Promise\<ArrayBuffer\> | File content as buffer |
716
+ | `file.text()` | Promise\<string\> | File content as text |
717
+ | `file.stream()` | ReadableStream | File as stream |