arckode-framework 1.2.3 → 1.3.1

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
@@ -317,13 +317,63 @@ await mail.send({ to: 'user@example.com', subject: 'Bienvenido', html: '<p>Hola<
317
317
  ### Storage
318
318
 
319
319
  ```ts
320
- import { StorageService } from 'arckode-framework/storage'
321
- import { LocalAdapter } from 'arckode-framework/storage/local'
320
+ import { StorageService } from 'arckode-framework/modules/storage'
321
+ import { LocalStorageAdapter } from 'arckode-framework/modules/storage/local-adapter'
322
+
323
+ // En composition-root.ts
324
+ const storage = new StorageService(
325
+ new LocalStorageAdapter('./uploads', '/uploads')
326
+ )
327
+
328
+ // Subir un archivo
329
+ const stored = await storage.upload(file, 'avatars')
330
+ // stored.url → '/uploads/avatars/1234567890-abc.jpg'
331
+ // stored.path → 'avatars/1234567890-abc.jpg'
332
+ ```
333
+
334
+ ### File Uploads (multipart/form-data)
335
+
336
+ El servidor detecta `Content-Type: multipart/form-data` automáticamente y parsea el body
337
+ sin dependencias externas. Los archivos quedan en `req.files`, los campos de texto en `req.body`.
338
+
339
+ ```ts
340
+ import type { UploadedFile } from 'arckode-framework'
341
+ import { ValidationError } from 'arckode-framework'
322
342
 
323
- const storage = new StorageService(new LocalAdapter({ path: './uploads' }))
324
- const url = await storage.save('avatars/user.png', fileBuffer)
343
+ router.post('/avatar', [auth.authenticate()], async (req) => {
344
+ const file = req.files?.['avatar']
345
+ if (!file) throw new ValidationError('Se requiere un archivo')
346
+
347
+ const stored = await storageService.upload(file, 'avatars')
348
+ return { status: 200, body: { url: stored.url } }
349
+ })
325
350
  ```
326
351
 
352
+ **Desde el cliente:**
353
+
354
+ ```ts
355
+ const form = new FormData()
356
+ form.append('avatar', blob, 'photo.jpg')
357
+ await fetch('/avatar', { method: 'POST', body: form })
358
+ ```
359
+
360
+ **Estructura de `UploadedFile`:**
361
+
362
+ ```ts
363
+ interface UploadedFile {
364
+ fieldName: string // nombre del campo en el form
365
+ originalName: string // nombre del archivo enviado
366
+ buffer: Buffer // contenido binario
367
+ mimeType: string // 'image/jpeg', 'application/pdf', etc.
368
+ size: number // bytes
369
+ }
370
+ ```
371
+
372
+ > Combiná con `bodyLimit()` para limitar el tamaño máximo:
373
+ > ```ts
374
+ > router.post('/avatar', [bodyLimit(5 * 1024 * 1024)], handler) // 5MB máx
375
+ > ```
376
+
327
377
  ---
328
378
 
329
379
  ## Middlewares
@@ -1065,6 +1065,14 @@ export class OrmTransactor implements Transactor {
1065
1065
  // 6. ROUTER + HTTP — Enrutamiento con middlewares y error handler
1066
1066
  // ═══════════════════════════════════════════════════════════════
1067
1067
 
1068
+ export interface UploadedFile {
1069
+ fieldName: string
1070
+ originalName: string
1071
+ buffer: Buffer
1072
+ mimeType: string
1073
+ size: number
1074
+ }
1075
+
1068
1076
  export interface HttpRequest {
1069
1077
  id: string
1070
1078
  method: string
@@ -1073,6 +1081,7 @@ export interface HttpRequest {
1073
1081
  query: Record<string, string>
1074
1082
  headers: Record<string, string>
1075
1083
  body: unknown
1084
+ files?: Record<string, UploadedFile>
1076
1085
  user?: { id: string; role: string }
1077
1086
  }
1078
1087
 
@@ -1318,6 +1327,17 @@ function buildEnvelope(status: number, body: unknown): string {
1318
1327
  return JSON.stringify({ success: true, data: body, meta: null, error: null } satisfies ApiResponse)
1319
1328
  }
1320
1329
 
1330
+ function indexOfBuffer(haystack: Buffer, needle: Buffer, offset = 0): number {
1331
+ const limit = haystack.length - needle.length
1332
+ outer: for (let i = offset; i <= limit; i++) {
1333
+ for (let j = 0; j < needle.length; j++) {
1334
+ if (haystack[i + j] !== needle[j]) continue outer
1335
+ }
1336
+ return i
1337
+ }
1338
+ return -1
1339
+ }
1340
+
1321
1341
  export class NodeServer implements ServerAdapter {
1322
1342
  private server?: ReturnType<typeof createNodeServer>
1323
1343
  private maxBodyBytes: number
@@ -1341,7 +1361,7 @@ export class NodeServer implements ServerAdapter {
1341
1361
 
1342
1362
  await requestStorage.run({ requestId, startTime: Date.now() }, async () => {
1343
1363
  try {
1344
- const body = await this.readBody(nodeReq, this.maxBodyBytes)
1364
+ const { body, files } = await this.readBody(nodeReq, this.maxBodyBytes)
1345
1365
 
1346
1366
  const url = new URL(nodeReq.url ?? '/', `http://${nodeReq.headers.host ?? 'localhost'}`)
1347
1367
  const query: Record<string, string> = {}
@@ -1355,6 +1375,7 @@ export class NodeServer implements ServerAdapter {
1355
1375
  query,
1356
1376
  headers: nodeReq.headers as Record<string, string>,
1357
1377
  body,
1378
+ ...(files ? { files } : {}),
1358
1379
  }
1359
1380
 
1360
1381
  const res = await handler(req)
@@ -1441,7 +1462,10 @@ export class NodeServer implements ServerAdapter {
1441
1462
  return typeof addr === 'object' && addr ? addr.port : this.port
1442
1463
  }
1443
1464
 
1444
- private readBody(req: IncomingMessage, maxBytes = 10 * 1024 * 1024): Promise<unknown> {
1465
+ private readBody(
1466
+ req: IncomingMessage,
1467
+ maxBytes = 10 * 1024 * 1024,
1468
+ ): Promise<{ body: unknown; files?: Record<string, UploadedFile> }> {
1445
1469
  return new Promise((resolve, reject) => {
1446
1470
  const chunks: Buffer[] = []
1447
1471
  let total = 0
@@ -1457,14 +1481,90 @@ export class NodeServer implements ServerAdapter {
1457
1481
  })
1458
1482
 
1459
1483
  req.on('end', () => {
1460
- const raw = Buffer.concat(chunks).toString()
1461
- if (!raw) return resolve(null)
1462
- try { resolve(JSON.parse(raw)) } catch { resolve(raw) }
1484
+ const rawBuffer = Buffer.concat(chunks)
1485
+ if (!rawBuffer.length) return resolve({ body: null })
1486
+
1487
+ const contentType = (req.headers['content-type'] ?? '').toLowerCase()
1488
+ const boundaryMatch = contentType.match(/multipart\/form-data;\s*boundary=(.+)/)
1489
+
1490
+ if (boundaryMatch) {
1491
+ const boundary = (boundaryMatch[1] ?? '').trim()
1492
+ const { fields, files } = this.parseMultipart(rawBuffer, boundary)
1493
+ return resolve({ body: fields, files })
1494
+ }
1495
+
1496
+ const raw = rawBuffer.toString()
1497
+ try { resolve({ body: JSON.parse(raw) }) } catch { resolve({ body: raw }) }
1463
1498
  })
1464
1499
 
1465
1500
  req.on('error', reject)
1466
1501
  })
1467
1502
  }
1503
+
1504
+ private parseMultipart(
1505
+ buffer: Buffer,
1506
+ boundary: string,
1507
+ ): { fields: Record<string, string>; files: Record<string, UploadedFile> } {
1508
+ const fields: Record<string, string> = {}
1509
+ const files: Record<string, UploadedFile> = {}
1510
+
1511
+ const firstDelim = Buffer.from(`--${boundary}`)
1512
+ const innerDelim = Buffer.from(`\r\n--${boundary}`)
1513
+ const doubleCRLF = Buffer.from('\r\n\r\n')
1514
+
1515
+ let pos = indexOfBuffer(buffer, firstDelim, 0)
1516
+ if (pos === -1) return { fields, files }
1517
+ pos += firstDelim.length
1518
+
1519
+ while (pos < buffer.length) {
1520
+ // Skip \r\n after boundary line, or detect closing --
1521
+ if (buffer[pos] === 0x2d && buffer[pos + 1] === 0x2d) break
1522
+ if (buffer[pos] === 0x0d && buffer[pos + 1] === 0x0a) pos += 2
1523
+ else break
1524
+
1525
+ const headerEnd = indexOfBuffer(buffer, doubleCRLF, pos)
1526
+ if (headerEnd === -1) break
1527
+
1528
+ const headerStr = buffer.subarray(pos, headerEnd).toString()
1529
+ pos = headerEnd + 4
1530
+
1531
+ const nextBound = indexOfBuffer(buffer, innerDelim, pos)
1532
+ if (nextBound === -1) break
1533
+
1534
+ const partBody = buffer.subarray(pos, nextBound)
1535
+ pos = nextBound + innerDelim.length
1536
+
1537
+ const headers: Record<string, string> = {}
1538
+ for (const line of headerStr.split('\r\n')) {
1539
+ const colon = line.indexOf(':')
1540
+ if (colon === -1) continue
1541
+ headers[line.slice(0, colon).toLowerCase().trim()] = line.slice(colon + 1).trim()
1542
+ }
1543
+
1544
+ const disp = headers['content-disposition'] ?? ''
1545
+ const nameMatch = /name="([^"]*)"/.exec(disp)
1546
+ const fileMatch = /filename="([^"]*)"/.exec(disp)
1547
+ if (!nameMatch) continue
1548
+
1549
+ const fieldName = nameMatch[1] ?? ''
1550
+
1551
+ if (fileMatch) {
1552
+ files[fieldName] = {
1553
+ fieldName,
1554
+ originalName: fileMatch[1] ?? '',
1555
+ buffer: partBody,
1556
+ mimeType: headers['content-type'] ?? 'application/octet-stream',
1557
+ size: partBody.length,
1558
+ }
1559
+ } else {
1560
+ fields[fieldName] = partBody.toString()
1561
+ }
1562
+
1563
+ if (buffer[pos] === 0x2d && buffer[pos + 1] === 0x2d) break
1564
+ }
1565
+
1566
+ return { fields, files }
1567
+ }
1468
1568
  }
1469
1569
 
1470
1570
  // ═══════════════════════════════════════════════════════════════
@@ -2038,4 +2138,7 @@ export default {
2038
2138
 
2039
2139
  // Response envelope
2040
2140
  // ApiResponse interface is exported as named export above
2141
+
2142
+ // File uploads (multipart/form-data)
2143
+ // UploadedFile interface is exported as named export above
2041
2144
  }
@@ -1,13 +1,8 @@
1
1
  // modules/storage/index.ts — Módulo de almacenamiento
2
2
  // Sube y sirve archivos via adapter (local disk, S3, etc.)
3
3
 
4
- export interface FileUpload {
5
- fieldName: string
6
- originalName: string
7
- buffer: Buffer
8
- mimeType: string
9
- size: number
10
- }
4
+ import type { UploadedFile as FileUpload } from '../../kernel/framework'
5
+ export type { UploadedFile as FileUpload } from '../../kernel/framework'
11
6
 
12
7
  export interface StoredFile {
13
8
  url: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arckode-framework",
3
- "version": "1.2.3",
3
+ "version": "1.3.1",
4
4
  "description": "AI-first TypeScript/Bun framework. Modular, SOLID, zero magic. The AI reads the composition root and knows everything.",
5
5
  "type": "module",
6
6
  "main": "./kernel/framework.ts",
@@ -17,6 +17,7 @@
17
17
  "./adapters/mysql": "./adapters/mysql.ts",
18
18
  "./modules/mail": "./modules/mail/index.ts",
19
19
  "./modules/storage": "./modules/storage/index.ts",
20
+ "./modules/storage/local-adapter": "./modules/storage/local-adapter.ts",
20
21
  "./modules/queue": "./modules/queue/index.ts",
21
22
  "./testing": "./kernel/testing.ts"
22
23
  },