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 +54 -4
- package/kernel/framework.ts +108 -5
- package/modules/storage/index.ts +2 -7
- package/package.json +2 -1
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 {
|
|
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
|
-
|
|
324
|
-
const
|
|
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
|
package/kernel/framework.ts
CHANGED
|
@@ -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(
|
|
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
|
|
1461
|
-
if (!
|
|
1462
|
-
|
|
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
|
}
|
package/modules/storage/index.ts
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
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.
|
|
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
|
},
|