@spfn/core 0.3.0-beta.4 → 0.3.0-beta.6

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.
Files changed (45) hide show
  1. package/README.md +183 -4
  2. package/dist/authz/index.js +1 -381
  3. package/dist/authz/index.js.map +1 -1
  4. package/dist/db/index.d.ts +173 -27
  5. package/dist/db/index.js +192 -57
  6. package/dist/db/index.js.map +1 -1
  7. package/dist/env/loader.js +24 -1
  8. package/dist/env/loader.js.map +1 -1
  9. package/dist/errors/index.js +1 -381
  10. package/dist/errors/index.js.map +1 -1
  11. package/dist/logger/index.js +0 -12
  12. package/dist/logger/index.js.map +1 -1
  13. package/dist/middleware/index.js +6 -387
  14. package/dist/middleware/index.js.map +1 -1
  15. package/dist/nextjs/index.d.ts +18 -1
  16. package/dist/nextjs/index.js +40 -1
  17. package/dist/nextjs/index.js.map +1 -1
  18. package/dist/nextjs/server.d.ts +34 -1
  19. package/dist/nextjs/server.js +14 -0
  20. package/dist/nextjs/server.js.map +1 -1
  21. package/dist/ops/index.d.ts +61 -6
  22. package/dist/ops/index.js +330 -30
  23. package/dist/ops/index.js.map +1 -1
  24. package/dist/server/index.js +24 -1
  25. package/dist/server/index.js.map +1 -1
  26. package/docs/file-upload.md +195 -333
  27. package/package.json +6 -5
  28. package/src/cache/README.md +330 -0
  29. package/src/codegen/README.md +516 -0
  30. package/src/config/README.md +326 -0
  31. package/src/contract/README.md +326 -0
  32. package/src/db/README.md +589 -0
  33. package/src/db/manager/README.md +500 -0
  34. package/src/db/schema/README.md +344 -0
  35. package/src/db/transaction/README.md +822 -0
  36. package/src/env/README.md +651 -0
  37. package/src/errors/README.md +429 -0
  38. package/src/event/README.md +736 -0
  39. package/src/job/README.md +514 -0
  40. package/src/logger/README.md +321 -0
  41. package/src/middleware/README.md +634 -0
  42. package/src/nextjs/README.md +608 -0
  43. package/src/route/README.md +738 -0
  44. package/src/security/README.md +100 -0
  45. package/src/server/README.md +704 -0
@@ -1,6 +1,13 @@
1
1
  # File Upload
2
2
 
3
- Complete guide for handling file uploads in SPFN applications.
3
+ SPFN provides `FileSchema()` and `FileArraySchema()` for type-safe file uploads within [route definitions](../src/route/README.md). Files are received as standard `File` objects through `formData` input.
4
+
5
+ > **They are functions — always call them.** `file: FileSchema()` is correct;
6
+ > `file: FileSchema` passes the function reference and produces an invalid schema. The
7
+ > same goes for `FileArraySchema()` and `OptionalFileSchema()`.
8
+ >
9
+ > `body` and `formData` are also mutually exclusive at runtime: the request's
10
+ > `Content-Type` decides which one is parsed. A multipart request never populates `body`.
4
11
 
5
12
  ## Basic Usage
6
13
 
@@ -15,8 +22,8 @@ export const uploadAvatar = route.post('/users/:id/avatar')
15
22
  params: Type.Object({ id: Type.String() }),
16
23
  formData: Type.Object({
17
24
  file: FileSchema(),
18
- description: Type.Optional(Type.String())
19
- })
25
+ description: Type.Optional(Type.String()),
26
+ }),
20
27
  })
21
28
  .handler(async (c) =>
22
29
  {
@@ -30,7 +37,6 @@ export const uploadAvatar = route.post('/users/:id/avatar')
30
37
 
31
38
  // Read file content
32
39
  const buffer = await file.arrayBuffer();
33
- const text = await file.text(); // for text files
34
40
 
35
41
  return c.created({ filename: file.name, size: file.size });
36
42
  });
@@ -45,8 +51,8 @@ export const uploadDocuments = route.post('/documents')
45
51
  .input({
46
52
  formData: Type.Object({
47
53
  files: FileArraySchema(),
48
- category: Type.String()
49
- })
54
+ category: Type.String(),
55
+ }),
50
56
  })
51
57
  .handler(async (c) =>
52
58
  {
@@ -57,7 +63,6 @@ export const uploadDocuments = route.post('/documents')
57
63
  files.map(async (file) =>
58
64
  {
59
65
  const buffer = await file.arrayBuffer();
60
- // Process each file...
61
66
  return { name: file.name, size: file.size };
62
67
  })
63
68
  );
@@ -66,45 +71,44 @@ export const uploadDocuments = route.post('/documents')
66
71
  });
67
72
  ```
68
73
 
69
- ### Mixed Fields
74
+ ### Mixed Fields (File + Text)
70
75
 
71
76
  ```typescript
77
+ import { route, FileSchema } from '@spfn/core/route';
78
+
72
79
  export const createPost = route.post('/posts')
73
80
  .input({
74
81
  formData: Type.Object({
75
82
  title: Type.String(),
76
83
  content: Type.String(),
77
- image: OptionalFileSchema(),
78
- tags: Type.Optional(Type.String()) // JSON string
79
- })
84
+ image: FileSchema(),
85
+ tags: Type.Optional(Type.String()), // JSON string
86
+ }),
80
87
  })
81
88
  .handler(async (c) =>
82
89
  {
83
90
  const { formData } = await c.data();
84
- const image = formData.image as File | undefined;
91
+ const image = formData.image as File;
85
92
 
86
93
  const post = await postRepo.create({
87
94
  title: formData.title,
88
95
  content: formData.content,
89
96
  tags: formData.tags ? JSON.parse(formData.tags) : [],
90
- imageUrl: image ? await saveFile(image) : null
97
+ imageUrl: await saveFile(image),
91
98
  });
92
99
 
93
100
  return c.created(post);
94
101
  });
95
102
  ```
96
103
 
97
- ---
98
-
99
104
  ## Validation
100
105
 
101
106
  ### Declarative Validation (Recommended)
102
107
 
103
- Use schema options for automatic validation:
108
+ Pass validation options directly to the schema for automatic enforcement:
104
109
 
105
110
  ```typescript
106
111
  import { route, FileSchema, FileArraySchema } from '@spfn/core/route';
107
- import { Type } from '@sinclair/typebox';
108
112
 
109
113
  // Single file with size and type constraints
110
114
  export const uploadAvatar = route.post('/avatars')
@@ -112,9 +116,9 @@ export const uploadAvatar = route.post('/avatars')
112
116
  formData: Type.Object({
113
117
  avatar: FileSchema({
114
118
  maxSize: 5 * 1024 * 1024, // 5MB
115
- allowedTypes: ['image/jpeg', 'image/png', 'image/webp']
116
- })
117
- })
119
+ allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
120
+ }),
121
+ }),
118
122
  })
119
123
  .handler(async (c) =>
120
124
  {
@@ -132,9 +136,9 @@ export const uploadDocuments = route.post('/documents')
132
136
  maxFiles: 5,
133
137
  minFiles: 1,
134
138
  maxSize: 10 * 1024 * 1024, // 10MB per file
135
- allowedTypes: ['application/pdf', 'application/msword']
136
- })
137
- })
139
+ allowedTypes: ['application/pdf', 'application/msword'],
140
+ }),
141
+ }),
138
142
  })
139
143
  .handler(async (c) =>
140
144
  {
@@ -144,20 +148,11 @@ export const uploadDocuments = route.post('/documents')
144
148
  });
145
149
  ```
146
150
 
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:
151
+ Validation errors are thrown automatically with a 400 status:
158
152
 
159
153
  ```json
160
154
  {
155
+ "__type": "ValidationError",
161
156
  "message": "Invalid form data",
162
157
  "fields": [
163
158
  {
@@ -165,13 +160,36 @@ Validation errors are thrown automatically with 400 status code and structured e
165
160
  "message": "File size 15.0MB exceeds maximum 5.0MB",
166
161
  "value": 15728640
167
162
  }
168
- ]
163
+ ],
164
+ "error": {
165
+ "code": "ValidationError",
166
+ "message": "Invalid form data",
167
+ "requestId": "req_1754380000000_9f2c1ab4e7d0"
168
+ }
169
169
  }
170
170
  ```
171
171
 
172
- ---
172
+ Every error body carries `__type` (what the web client restores an error class from) and
173
+ an `error` envelope with `code` / `message` / `requestId` (what a client in another
174
+ language classifies on). For a file-array field the `path` includes the index —
175
+ `/files/2` for the third file, `/files` for a count violation (`maxFiles` / `minFiles`).
176
+
177
+ > **A missing file field is not a validation error.** Validation walks the fields the
178
+ > request actually sent, so a `FileSchema()` field the client omitted produces no error —
179
+ > the handler just receives `undefined`. `formData.avatar as File` is a cast, not a
180
+ > guarantee. Check for the file yourself before using it.
173
181
 
174
- ### Manual Validation (Advanced)
182
+ ### Validation Options
183
+
184
+ | Option | Type | Applies To | Description |
185
+ |--------|------|------------|-------------|
186
+ | `maxSize` | number | Both | Maximum file size in bytes |
187
+ | `minSize` | number | Both | Minimum file size in bytes |
188
+ | `allowedTypes` | string[] | Both | Allowed MIME types |
189
+ | `maxFiles` | number | FileArraySchema | Maximum file count |
190
+ | `minFiles` | number | FileArraySchema | Minimum file count |
191
+
192
+ ### Manual Validation
175
193
 
176
194
  For custom validation logic, validate in the handler:
177
195
 
@@ -183,15 +201,14 @@ const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif
183
201
  export const uploadImage = route.post('/images')
184
202
  .input({
185
203
  formData: Type.Object({
186
- image: FileSchema()
187
- })
204
+ image: FileSchema(),
205
+ }),
188
206
  })
189
207
  .handler(async (c) =>
190
208
  {
191
209
  const { formData } = await c.data();
192
210
  const file = formData.image as File;
193
211
 
194
- // Custom validation logic
195
212
  if (!ALLOWED_IMAGE_TYPES.includes(file.type))
196
213
  {
197
214
  throw new ValidationError({
@@ -199,8 +216,8 @@ export const uploadImage = route.post('/images')
199
216
  fields: [{
200
217
  path: '/image',
201
218
  message: `Allowed types: ${ALLOWED_IMAGE_TYPES.join(', ')}`,
202
- value: file.type
203
- }]
219
+ value: file.type,
220
+ }],
204
221
  });
205
222
  }
206
223
 
@@ -208,116 +225,75 @@ export const uploadImage = route.post('/images')
208
225
  });
209
226
  ```
210
227
 
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
- }
228
+ ## Storage Patterns
238
229
 
239
- // Process file...
240
- });
241
- ```
230
+ ### `@spfn/storage` (recommended)
242
231
 
243
- ### Reusable Validation Helper
232
+ SPFN ships provider-agnostic object storage — S3-compatible services (S3, R2, MinIO,
233
+ Wasabi), Google Cloud Storage, and the local filesystem behind one interface. Prefer it
234
+ over calling a provider SDK directly: every object operation validates its key before it
235
+ reaches the provider (rejecting `..` segments, leading `/`, backslashes, control
236
+ characters and URLs), so a key built from user input cannot escape its prefix.
244
237
 
245
238
  ```typescript
246
- // lib/file-validation.ts
247
- import { ValidationError } from '@spfn/core/errors';
239
+ import { getStorageService, randomKey } from '@spfn/storage/server';
248
240
 
249
- interface FileValidationOptions
241
+ async function saveUpload(file: File): Promise<string>
250
242
  {
251
- maxSize?: number;
252
- allowedTypes?: string[];
253
- required?: boolean;
243
+ const storage = await getStorageService();
244
+ const key = randomKey('public/uploads', file.name.split('.').pop() || 'bin');
245
+
246
+ await storage.upload(key, Buffer.from(await file.arrayBuffer()), file.type);
247
+
248
+ return storage.getPublicUrl(key);
254
249
  }
250
+ ```
255
251
 
256
- export function validateFile(
257
- file: File | undefined,
258
- fieldName: string,
259
- options: FileValidationOptions = {}
260
- ): void
261
- {
262
- const { maxSize, allowedTypes, required = true } = options;
252
+ The provider comes from `STORAGE_PROVIDER` (`local` / `s3` / `gcs`), defaulting to `local`
253
+ in development and `s3` in production. Private objects are read back with
254
+ `storage.getDownloadUrl(key)` (a presigned GET) or `storage.getStream(key)`.
263
255
 
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
- }
256
+ A `public/` key prefix is meaningful **on GCS only**, where it routes the object to the
257
+ public bucket instead of the private one. On S3-compatible providers and local there is a
258
+ single bucket, and `getPublicUrl()` just prepends the configured public base URL to any
259
+ key — the prefix is a convention you must back with your own bucket policy.
275
260
 
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
- }
261
+ ### Presigned upload (large files)
287
262
 
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
- }
263
+ For large files, don't route the bytes through your API process at all — sign an upload,
264
+ let the browser `PUT` straight to the returned `uploadUrl`, then confirm it:
300
265
 
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;
266
+ ```typescript
267
+ const { uploadUrl, requiredHeaders } = await storage.getUploadUrl({
268
+ key,
269
+ contentType: 'image/webp',
270
+ contentLength: exactSize, // signed on both S3 and GCS
271
+ temp: true, // unconfirmed until finalized
272
+ });
308
273
 
309
- validateFile(file, 'image', {
310
- maxSize: 5 * 1024 * 1024,
311
- allowedTypes: ['image/jpeg', 'image/png', 'image/webp']
312
- });
274
+ // browser PUTs to uploadUrl, sending every requiredHeaders entry verbatim
313
275
 
314
- // File is valid...
315
- });
276
+ await storage.finalizeObject(key);
316
277
  ```
317
278
 
318
- ---
279
+ Three constraints decide whether this is safe:
319
280
 
320
- ## Storage Patterns
281
+ | Constraint | Behaviour |
282
+ |---|---|
283
+ | `contentLength` (exact size) | Signed on **both** S3-compatible and GCS. A mismatched size fails. |
284
+ | `maxBytes` (upper bound) | Enforced on **GCS only**. A presigned PUT cannot sign a size range, so S3, R2, MinIO and Wasabi **ignore it silently**. |
285
+ | Local filesystem provider | Presigned upload is **not supported** — `getUploadUrl()` throws. Use the direct `upload()` path in local dev. |
286
+
287
+ A client can declare one size and send another, so a server-side check of a
288
+ client-declared size binds nothing. If you only know an upper bound and must enforce it on
289
+ S3, verify the size after upload.
290
+
291
+ `temp: true` marks the upload unconfirmed so abandoned uploads don't accumulate, and
292
+ `finalizeObject(key)` confirms it (idempotent; it rejects if neither the temp nor the final
293
+ object exists). The package does **not** delete orphans itself — it tags them
294
+ (`lifecycle=temp` on S3) or stages them under `tmp/<key>` (GCS), and you configure the
295
+ bucket lifecycle rule that expires them. On GCS a temp object is not readable at its final
296
+ key until finalized; on S3 it is.
321
297
 
322
298
  ### Local File System
323
299
 
@@ -342,18 +318,6 @@ async function saveToLocal(file: File, subdir: string = ''): Promise<string>
342
318
 
343
319
  return filepath;
344
320
  }
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
321
  ```
358
322
 
359
323
  ### AWS S3
@@ -375,47 +339,26 @@ async function uploadToS3(file: File, prefix: string = ''): Promise<string>
375
339
  Key: key,
376
340
  Body: Buffer.from(await file.arrayBuffer()),
377
341
  ContentType: file.type,
378
- Metadata: {
379
- originalName: file.name
380
- }
342
+ Metadata: { originalName: file.name },
381
343
  }));
382
344
 
383
345
  return `https://${BUCKET}.s3.amazonaws.com/${key}`;
384
346
  }
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
347
  ```
406
348
 
407
349
  ### Cloudflare R2
408
350
 
409
351
  ```typescript
410
352
  import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
353
+ import { randomUUID } from 'crypto';
411
354
 
412
355
  const r2 = new S3Client({
413
356
  region: 'auto',
414
357
  endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
415
358
  credentials: {
416
359
  accessKeyId: process.env.R2_ACCESS_KEY_ID!,
417
- secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!
418
- }
360
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
361
+ },
419
362
  });
420
363
 
421
364
  async function uploadToR2(file: File, prefix: string = ''): Promise<string>
@@ -426,15 +369,13 @@ async function uploadToR2(file: File, prefix: string = ''): Promise<string>
426
369
  Bucket: process.env.R2_BUCKET,
427
370
  Key: key,
428
371
  Body: Buffer.from(await file.arrayBuffer()),
429
- ContentType: file.type
372
+ ContentType: file.type,
430
373
  }));
431
374
 
432
375
  return `${process.env.R2_PUBLIC_URL}/${key}`;
433
376
  }
434
377
  ```
435
378
 
436
- ---
437
-
438
379
  ## Streaming (Large Files)
439
380
 
440
381
  For large files, use streaming to avoid memory issues:
@@ -443,6 +384,7 @@ For large files, use streaming to avoid memory issues:
443
384
  import { Readable } from 'stream';
444
385
  import { createWriteStream } from 'fs';
445
386
  import { pipeline } from 'stream/promises';
387
+ import { randomUUID } from 'crypto';
446
388
 
447
389
  export const uploadLargeFile = route.post('/large-files')
448
390
  .handler(async (c) =>
@@ -456,11 +398,9 @@ export const uploadLargeFile = route.post('/large-files')
456
398
  throw new ValidationError({ message: 'File required' });
457
399
  }
458
400
 
459
- // Stream to disk
460
401
  const outputPath = `./uploads/${randomUUID()}.bin`;
461
402
  const writeStream = createWriteStream(outputPath);
462
403
 
463
- // Convert File to Node.js Readable stream
464
404
  const reader = file.stream().getReader();
465
405
  const nodeStream = new Readable({
466
406
  async read()
@@ -474,7 +414,7 @@ export const uploadLargeFile = route.post('/large-files')
474
414
  {
475
415
  this.push(Buffer.from(value));
476
416
  }
477
- }
417
+ },
478
418
  });
479
419
 
480
420
  await pipeline(nodeStream, writeStream);
@@ -483,17 +423,70 @@ export const uploadLargeFile = route.post('/large-files')
483
423
  });
484
424
  ```
485
425
 
486
- ---
426
+ ## Client Usage
427
+
428
+ ### SPFN API Client (Recommended)
429
+
430
+ The generated API client handles `FormData` construction automatically:
431
+
432
+ ```typescript
433
+ import { api } from '@/lib/api';
434
+
435
+ // Single file upload
436
+ const result = await api.uploadAvatar.call({
437
+ params: { id: '123' },
438
+ formData: {
439
+ file: fileInput.files[0],
440
+ description: 'Profile photo',
441
+ },
442
+ });
443
+
444
+ // Multiple files
445
+ const docs = await api.uploadDocuments.call({
446
+ formData: {
447
+ files: Array.from(fileInput.files),
448
+ category: 'reports',
449
+ },
450
+ });
451
+ ```
452
+
453
+ ### Fetch API
454
+
455
+ For direct backend calls (bypassing RPC proxy):
456
+
457
+ ```typescript
458
+ const formData = new FormData();
459
+ formData.append('file', fileInput.files[0]);
460
+ formData.append('description', 'My file');
461
+
462
+ const response = await fetch('/api/upload', {
463
+ method: 'POST',
464
+ body: formData,
465
+ // Don't set Content-Type - browser sets it with boundary
466
+ });
467
+ ```
468
+
469
+ ### curl
470
+
471
+ ```bash
472
+ # Single file
473
+ curl -X POST http://localhost:3000/upload \
474
+ -F "file=@./document.pdf" \
475
+ -F "description=Important document"
476
+
477
+ # Multiple files
478
+ curl -X POST http://localhost:3000/upload-multiple \
479
+ -F "files=@./file1.txt" \
480
+ -F "files=@./file2.txt"
481
+ ```
487
482
 
488
483
  ## Security Best Practices
489
484
 
490
485
  ### 1. Always Validate MIME Types
491
486
 
492
487
  ```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
488
+ // Don't trust file extensions - check MIME type
489
+ // Consider using magic bytes for true type detection
497
490
  import { fileTypeFromBuffer } from 'file-type';
498
491
 
499
492
  const buffer = Buffer.from(await file.arrayBuffer());
@@ -509,9 +502,6 @@ if (!detected || !ALLOWED_TYPES.includes(detected.mime))
509
502
 
510
503
  ```typescript
511
504
  // Never use user-provided filenames directly
512
- const userFilename = file.name; // potentially malicious
513
-
514
- // Generate safe filename
515
505
  const safeFilename = `${randomUUID()}.${getExtension(file.type)}`;
516
506
 
517
507
  function getExtension(mimeType: string): string
@@ -520,39 +510,13 @@ function getExtension(mimeType: string): string
520
510
  'image/jpeg': 'jpg',
521
511
  'image/png': 'png',
522
512
  'image/webp': 'webp',
523
- 'application/pdf': 'pdf'
513
+ 'application/pdf': 'pdf',
524
514
  };
525
515
  return map[mimeType] || 'bin';
526
516
  }
527
517
  ```
528
518
 
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
519
+ ### 3. Store Outside Web Root
556
520
 
557
521
  ```typescript
558
522
  // Files should not be directly accessible via URL
@@ -568,127 +532,24 @@ export const getFile = route.get('/files/:id')
568
532
 
569
533
  if (!file || !canAccess(c.raw.get('user'), file))
570
534
  {
571
- throw new NotFoundError();
535
+ throw new NotFoundError({ resource: 'File' });
572
536
  }
573
537
 
574
- // Stream file from secure location
575
538
  const buffer = await readFile(file.path);
576
539
  return new Response(buffer, {
577
540
  headers: {
578
541
  'Content-Type': file.mimeType,
579
- 'Content-Disposition': `attachment; filename="${file.originalName}"`
580
- }
542
+ 'Content-Disposition': `attachment; filename="${file.originalName}"`,
543
+ },
581
544
  });
582
545
  });
583
546
  ```
584
547
 
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
- ---
548
+ A handler that returns a raw `Response` has it passed through as-is, but the typed client
549
+ then infers the response as `Response` rather than a concrete shape. That trade-off is
550
+ fine for a file download and wrong for a JSON endpoint.
616
551
 
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
552
+ ## Schema Reference
692
553
 
693
554
  | Schema | Description |
694
555
  |--------|-------------|
@@ -699,19 +560,20 @@ curl -X POST http://localhost:3000/upload-multiple \
699
560
  | `OptionalFileSchema()` | Optional single File |
700
561
  | `OptionalFileSchema(options)` | Optional File with validation |
701
562
 
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) |
563
+ ## File Properties
709
564
 
710
- | File Property | Type | Description |
711
- |---------------|------|-------------|
565
+ | Property | Type | Description |
566
+ |----------|------|-------------|
712
567
  | `file.name` | string | Original filename |
713
568
  | `file.size` | number | Size in bytes |
714
569
  | `file.type` | string | MIME type |
715
570
  | `file.arrayBuffer()` | Promise\<ArrayBuffer\> | File content as buffer |
716
571
  | `file.text()` | Promise\<string\> | File content as text |
717
572
  | `file.stream()` | ReadableStream | File as stream |
573
+
574
+ ## Related
575
+
576
+ - `@spfn/storage` - object storage (S3 / GCS / local), presigned uploads, key validation
577
+ - [Route Definition](../src/route/README.md) - `formData` input type
578
+ - [Next.js Integration](../src/nextjs/README.md) - Upload files through RPC proxy
579
+ - [Error Handling](../src/errors/README.md) - `ValidationError` for file errors