arckode-framework 1.3.1 → 1.4.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.
Files changed (65) hide show
  1. package/adapters/jwt.ts +6 -4
  2. package/adapters/mysql.ts +7 -2
  3. package/adapters/postgres.ts +37 -0
  4. package/adapters/sqlite.ts +7 -1
  5. package/adapters/vendor.d.ts +48 -0
  6. package/cli/analyze/checks.ts +333 -0
  7. package/cli/analyze/index.ts +44 -0
  8. package/cli/analyze/report.ts +107 -0
  9. package/cli/analyze/types.ts +46 -0
  10. package/cli/analyze/utils.ts +36 -0
  11. package/cli/analyze.ts +2 -647
  12. package/cli/commands/db-migrate.ts +213 -89
  13. package/cli/commands/db-seed.ts +97 -32
  14. package/cli/commands/db-utils.ts +192 -0
  15. package/cli/commands/new.ts +175 -0
  16. package/cli/commands/routes.ts +94 -0
  17. package/cli/index.ts +57 -404
  18. package/cli/stubs/claude-md-stub.ts +21 -8
  19. package/cli/stubs/module/core.ts +162 -0
  20. package/cli/stubs/module/data.ts +171 -0
  21. package/cli/stubs/module/index.ts +5 -0
  22. package/cli/stubs/module/service.ts +198 -0
  23. package/cli/stubs/module/types.ts +12 -0
  24. package/cli/stubs/module-stub.ts +2 -552
  25. package/kernel/auth.ts +114 -0
  26. package/kernel/cache.ts +37 -0
  27. package/kernel/config.ts +129 -0
  28. package/kernel/container.ts +64 -0
  29. package/kernel/db/orm-migrate.ts +136 -0
  30. package/kernel/db/orm-repository.ts +45 -0
  31. package/kernel/db/orm-utils.ts +93 -0
  32. package/kernel/db/orm.ts +254 -0
  33. package/kernel/db/transactor.ts +17 -0
  34. package/kernel/db/types.ts +72 -0
  35. package/kernel/errors.ts +102 -0
  36. package/kernel/framework.default.ts +41 -0
  37. package/kernel/framework.ts +8 -2144
  38. package/kernel/http/router.ts +131 -0
  39. package/kernel/http/server.ts +303 -0
  40. package/kernel/http/types.ts +56 -0
  41. package/kernel/index.ts +25 -0
  42. package/kernel/logger.ts +50 -0
  43. package/kernel/middlewares.ts +38 -21
  44. package/kernel/modules/create-module.ts +5 -0
  45. package/kernel/modules/system.ts +149 -0
  46. package/kernel/modules/types.ts +46 -0
  47. package/kernel/seeds.ts +48 -0
  48. package/kernel/static.ts +11 -2
  49. package/kernel/testing.ts +8 -3
  50. package/kernel/validator.ts +116 -0
  51. package/modules/events/index.ts +19 -3
  52. package/modules/mail/index.ts +14 -2
  53. package/modules/storage/local-adapter.ts +19 -5
  54. package/modules/ws/index.ts +123 -18
  55. package/package.json +8 -11
  56. package/skills/auth/SKILL.md +36 -220
  57. package/skills/cli/SKILL.md +32 -251
  58. package/skills/config/SKILL.md +30 -239
  59. package/skills/connectors/SKILL.md +32 -295
  60. package/skills/helpers/SKILL.md +26 -195
  61. package/skills/middlewares/SKILL.md +30 -267
  62. package/skills/orm/SKILL.md +42 -349
  63. package/skills/realtime/SKILL.md +22 -297
  64. package/skills/services/SKILL.md +40 -183
  65. package/skills/testing/SKILL.md +34 -266
@@ -0,0 +1,131 @@
1
+ import { ErrorContract } from '../errors'
2
+ import type { Logger } from '../logger'
3
+ import type { HttpRequest, HttpResponse, MiddlewareHandler, RouteHandler } from './types'
4
+
5
+ interface RouteEntry {
6
+ method: string
7
+ pattern: RegExp
8
+ paramNames: string[]
9
+ handler: RouteHandler
10
+ middlewares: MiddlewareHandler[]
11
+ }
12
+
13
+ export class Router {
14
+ private routes: RouteEntry[] = []
15
+ private globalMiddlewares: MiddlewareHandler[] = []
16
+ private logger?: Logger
17
+
18
+ setLogger(logger: Logger): void { this.logger = logger }
19
+
20
+ use(middleware: MiddlewareHandler): void {
21
+ this.globalMiddlewares.push(middleware)
22
+ }
23
+
24
+ private add(method: string, path: string, handler: RouteHandler, middlewares: MiddlewareHandler[] = []): void {
25
+ const paramNames: string[] = []
26
+ const parts = path.split('/').filter(Boolean)
27
+ const regexParts = parts.map(part => {
28
+ if (part.startsWith(':')) {
29
+ const rawName = part.slice(1)
30
+ if (rawName.endsWith('(*)')) {
31
+ paramNames.push(rawName.slice(0, -3))
32
+ return '(.*)'
33
+ }
34
+ paramNames.push(rawName)
35
+ return '([^/]+)'
36
+ }
37
+ return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
38
+ })
39
+
40
+ this.routes.push({
41
+ method,
42
+ pattern: new RegExp(`^/${regexParts.join('/')}/?$`),
43
+ paramNames,
44
+ handler,
45
+ middlewares,
46
+ })
47
+ }
48
+
49
+ private resolveRouteArgs(
50
+ handlerOrMw: RouteHandler | MiddlewareHandler[],
51
+ handlerOrUndefined?: RouteHandler | MiddlewareHandler[],
52
+ ): { handler: RouteHandler; mw: MiddlewareHandler[] } {
53
+ if (Array.isArray(handlerOrMw)) {
54
+ return { handler: handlerOrUndefined as RouteHandler, mw: handlerOrMw }
55
+ }
56
+ return { handler: handlerOrMw, mw: (handlerOrUndefined as MiddlewareHandler[] | undefined) ?? [] }
57
+ }
58
+
59
+ get(path: string, handlerOrMw: RouteHandler | MiddlewareHandler[], h?: RouteHandler | MiddlewareHandler[]): void { const { handler, mw } = this.resolveRouteArgs(handlerOrMw, h); this.add('GET', path, handler, mw) }
60
+ post(path: string, handlerOrMw: RouteHandler | MiddlewareHandler[], h?: RouteHandler | MiddlewareHandler[]): void { const { handler, mw } = this.resolveRouteArgs(handlerOrMw, h); this.add('POST', path, handler, mw) }
61
+ put(path: string, handlerOrMw: RouteHandler | MiddlewareHandler[], h?: RouteHandler | MiddlewareHandler[]): void { const { handler, mw } = this.resolveRouteArgs(handlerOrMw, h); this.add('PUT', path, handler, mw) }
62
+ patch(path: string, handlerOrMw: RouteHandler | MiddlewareHandler[], h?: RouteHandler | MiddlewareHandler[]): void { const { handler, mw } = this.resolveRouteArgs(handlerOrMw, h); this.add('PATCH', path, handler, mw) }
63
+ delete(path: string, handlerOrMw: RouteHandler | MiddlewareHandler[], h?: RouteHandler | MiddlewareHandler[]): void { const { handler, mw } = this.resolveRouteArgs(handlerOrMw, h); this.add('DELETE', path, handler, mw) }
64
+ options(path: string, handlerOrMw: RouteHandler | MiddlewareHandler[], h?: RouteHandler | MiddlewareHandler[]): void { const { handler, mw } = this.resolveRouteArgs(handlerOrMw, h); this.add('OPTIONS', path, handler, mw) }
65
+
66
+ async resolve(method: string, path: string, extras?: Partial<HttpRequest>): Promise<HttpResponse> {
67
+ const reqId = crypto.randomUUID().slice(0, 8)
68
+
69
+ const buildRequest = (route: RouteEntry, match: RegExpMatchArray): HttpRequest => {
70
+ const params: Record<string, string> = {}
71
+ route.paramNames.forEach((name, i) => { params[name] = decodeURIComponent(match[i + 1] ?? '') })
72
+ return {
73
+ id: reqId,
74
+ method,
75
+ path,
76
+ params,
77
+ query: extras?.query ?? {},
78
+ headers: extras?.headers ?? {},
79
+ body: extras?.body ?? null,
80
+ user: extras?.user,
81
+ }
82
+ }
83
+
84
+ const runAll = async (req: HttpRequest, routeMiddlewares: MiddlewareHandler[], routeHandler: RouteHandler): Promise<HttpResponse> => {
85
+ const allMiddlewares = [...this.globalMiddlewares, ...routeMiddlewares]
86
+ let index = 0
87
+ const next = async (): Promise<HttpResponse> => {
88
+ if (index < allMiddlewares.length) {
89
+ const mw = allMiddlewares[index++]!
90
+ return mw(req, next)
91
+ }
92
+ return routeHandler(req)
93
+ }
94
+ try {
95
+ return await next()
96
+ } catch (error) {
97
+ if (error instanceof ErrorContract) {
98
+ return { status: error.httpStatus, body: error.toJSON() }
99
+ }
100
+ if (this.logger) {
101
+ const stack = error instanceof Error ? error.stack : String(error)
102
+ this.logger.error(`Unhandled error in ${method} ${path}`, { stack, requestId: reqId })
103
+ }
104
+ return { status: 500, body: { error: 'Error interno del servidor', code: 'INTERNAL_ERROR' } }
105
+ }
106
+ }
107
+
108
+ if (method === 'OPTIONS') {
109
+ let anyMatch: { route: RouteEntry; match: RegExpMatchArray } | null = null
110
+ for (const route of this.routes) {
111
+ const m = path.match(route.pattern)
112
+ if (m) { anyMatch = { route, match: m }; break }
113
+ }
114
+ if (anyMatch) {
115
+ const req = buildRequest(anyMatch.route, anyMatch.match)
116
+ return runAll(req, [], () => Promise.resolve({ status: 204, body: null }))
117
+ }
118
+ }
119
+
120
+ for (const route of this.routes) {
121
+ if (route.method !== method) continue
122
+ const match = path.match(route.pattern)
123
+ if (!match) continue
124
+
125
+ const req = buildRequest(route, match)
126
+ return runAll(req, route.middlewares, route.handler)
127
+ }
128
+
129
+ return { status: 404, body: { error: 'Route not found', code: 'NOT_FOUND' } }
130
+ }
131
+ }
@@ -0,0 +1,303 @@
1
+ import { createServer as createNodeServer, IncomingMessage, ServerResponse } from 'node:http'
2
+ import { AsyncLocalStorage } from 'node:async_hooks'
3
+ import { PayloadTooLargeError } from '../errors'
4
+ import type { Logger } from '../logger'
5
+ import type { ApiResponse, HttpRequest, HttpResponse, UploadedFile } from './types'
6
+
7
+ export const requestStorage = new AsyncLocalStorage<{ requestId: string; startTime: number }>()
8
+
9
+ export function getRequestId(): string | undefined {
10
+ return requestStorage.getStore()?.requestId
11
+ }
12
+
13
+ export function getRequestElapsed(): number | undefined {
14
+ const store = requestStorage.getStore()
15
+ return store ? Date.now() - store.startTime : undefined
16
+ }
17
+
18
+ export interface ServerAdapter {
19
+ start(handler: (req: HttpRequest) => Promise<HttpResponse>): Promise<void>
20
+ stop(): Promise<void>
21
+ }
22
+
23
+ function buildEnvelope(status: number, body: unknown): string {
24
+ if (status >= 400) {
25
+ const b = (body ?? {}) as Record<string, unknown>
26
+ return JSON.stringify({
27
+ success: false,
28
+ data: null,
29
+ meta: null,
30
+ error: {
31
+ code: (b.code ?? 'ERROR') as string,
32
+ message: (b.error ?? 'Error') as string,
33
+ details: b.details ?? null,
34
+ },
35
+ } satisfies ApiResponse)
36
+ }
37
+
38
+ if (body === null || body === undefined) {
39
+ return JSON.stringify({ success: true, data: null, meta: null, error: null } satisfies ApiResponse)
40
+ }
41
+
42
+ const b = body as Record<string, unknown>
43
+
44
+ if (Array.isArray(b.data) && (b.pagination !== undefined || b.total !== undefined)) {
45
+ const pagination = b.pagination ?? { total: b.total, limit: b.limit, offset: b.offset, pages: b.pages }
46
+ return JSON.stringify({
47
+ success: true,
48
+ data: b.data,
49
+ meta: { pagination },
50
+ error: null,
51
+ } satisfies ApiResponse)
52
+ }
53
+
54
+ return JSON.stringify({ success: true, data: body, meta: null, error: null } satisfies ApiResponse)
55
+ }
56
+
57
+ function indexOfBuffer(haystack: Buffer, needle: Buffer, offset = 0): number {
58
+ const limit = haystack.length - needle.length
59
+ outer: for (let i = offset; i <= limit; i++) {
60
+ for (let j = 0; j < needle.length; j++) {
61
+ if (haystack[i + j] !== needle[j]) continue outer
62
+ }
63
+ return i
64
+ }
65
+ return -1
66
+ }
67
+
68
+ export class NodeServer implements ServerAdapter {
69
+ private server?: ReturnType<typeof createNodeServer>
70
+ private maxBodyBytes: number
71
+ private drainTimeoutMs: number
72
+ private activeRequests = 0
73
+
74
+ constructor(
75
+ private port: number,
76
+ private logger: Logger,
77
+ opts: { maxBodyBytes?: number; drainTimeoutMs?: number } = {},
78
+ ) {
79
+ this.maxBodyBytes = opts.maxBodyBytes ?? 10 * 1024 * 1024
80
+ this.drainTimeoutMs = opts.drainTimeoutMs ?? 30_000
81
+ }
82
+
83
+ async start(handler: (req: HttpRequest) => Promise<HttpResponse>): Promise<void> {
84
+ return new Promise((resolve) => {
85
+ this.server = createNodeServer(async (nodeReq: IncomingMessage, nodeRes: ServerResponse) => {
86
+ this.activeRequests++
87
+ const requestId = crypto.randomUUID().slice(0, 8)
88
+
89
+ await requestStorage.run({ requestId, startTime: Date.now() }, async () => {
90
+ try {
91
+ const { body, files } = await this.readBody(nodeReq, this.maxBodyBytes)
92
+
93
+ const url = new URL(nodeReq.url ?? '/', `http://${nodeReq.headers.host ?? 'localhost'}`)
94
+ const query: Record<string, string> = {}
95
+ url.searchParams.forEach((value, key) => { query[key] = value })
96
+
97
+ const req: HttpRequest = {
98
+ id: requestId,
99
+ method: nodeReq.method ?? 'GET',
100
+ path: url.pathname,
101
+ params: {},
102
+ query,
103
+ headers: nodeReq.headers as Record<string, string>,
104
+ body,
105
+ remoteAddress: nodeReq.socket?.remoteAddress,
106
+ ...(files ? { files } : {}),
107
+ }
108
+
109
+ const res = await handler(req)
110
+
111
+ if (res.stream) {
112
+ nodeRes.writeHead(res.status, {
113
+ 'Content-Type': 'text/event-stream',
114
+ 'Cache-Control': 'no-cache',
115
+ Connection: 'keep-alive',
116
+ 'X-Request-Id': req.id,
117
+ ...res.headers,
118
+ })
119
+ try {
120
+ for await (const chunk of res.stream) {
121
+ nodeRes.write(`data: ${chunk}\n\n`)
122
+ }
123
+ } finally {
124
+ nodeRes.end()
125
+ }
126
+ return
127
+ }
128
+
129
+ // RFC 7231 §6.3.5 — 204 No Content: no body
130
+ if (res.status === 204) {
131
+ nodeRes.writeHead(204, { 'X-Request-Id': req.id, ...res.headers })
132
+ nodeRes.end()
133
+ return
134
+ }
135
+
136
+ const isBuffer = Buffer.isBuffer(res.body)
137
+ const responseBody = isBuffer ? res.body : buildEnvelope(res.status, res.body)
138
+ nodeRes.writeHead(res.status, {
139
+ 'Content-Type': 'application/json',
140
+ 'X-Request-Id': req.id,
141
+ ...res.headers,
142
+ })
143
+ nodeRes.end(responseBody)
144
+ } catch (error) {
145
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
146
+ const httpStatus = (error as any)?.httpStatus as number | undefined
147
+ if (httpStatus) {
148
+ const msg = httpStatus < 500
149
+ ? (error as Error).message
150
+ : 'Error interno'
151
+ if (httpStatus >= 500) {
152
+ this.logger.error('Server error', { error: String(error), httpStatus })
153
+ }
154
+ nodeRes.writeHead(httpStatus, { 'Content-Type': 'application/json' })
155
+ nodeRes.end(buildEnvelope(httpStatus, { error: msg, code: 'REQUEST_ERROR' }))
156
+ return
157
+ }
158
+
159
+ const stack = error instanceof Error ? error.stack : String(error)
160
+ this.logger.error('Error no manejado en HTTP', { error: String(error), stack })
161
+ nodeRes.writeHead(500, { 'Content-Type': 'application/json' })
162
+ nodeRes.end(buildEnvelope(500, { error: 'Error interno', code: 'INTERNAL_ERROR' }))
163
+ } finally {
164
+ this.activeRequests--
165
+ }
166
+ })
167
+ })
168
+
169
+ this.server.listen(this.port, () => {
170
+ this.logger.info(`Servidor HTTP escuchando en :${this.port}`)
171
+ resolve()
172
+ })
173
+ })
174
+ }
175
+
176
+ async stop(): Promise<void> {
177
+ return new Promise((resolve) => {
178
+ let resolved = false
179
+ const done = () => { if (!resolved) { resolved = true; resolve() } }
180
+
181
+ this.server?.close(done)
182
+
183
+ const deadline = Date.now() + this.drainTimeoutMs
184
+ const poll = setInterval(() => {
185
+ if (this.activeRequests === 0 || Date.now() >= deadline) {
186
+ clearInterval(poll)
187
+ if (this.activeRequests > 0) {
188
+ this.logger.warn(`Graceful shutdown: ${this.activeRequests} request(s) sin terminar, forzando cierre`)
189
+ }
190
+ done()
191
+ }
192
+ }, 50)
193
+ })
194
+ }
195
+
196
+ getPort(): number {
197
+ const addr = this.server?.address()
198
+ return typeof addr === 'object' && addr ? addr.port : this.port
199
+ }
200
+
201
+ private readBody(
202
+ req: IncomingMessage,
203
+ maxBytes = 10 * 1024 * 1024,
204
+ ): Promise<{ body: unknown; files?: Record<string, UploadedFile> }> {
205
+ return new Promise((resolve, reject) => {
206
+ const chunks: Buffer[] = []
207
+ let total = 0
208
+
209
+ req.on('data', (chunk: Buffer) => {
210
+ total += chunk.length
211
+ if (total > maxBytes) {
212
+ req.destroy()
213
+ reject(new PayloadTooLargeError())
214
+ return
215
+ }
216
+ chunks.push(chunk)
217
+ })
218
+
219
+ req.on('end', () => {
220
+ const rawBuffer = Buffer.concat(chunks)
221
+ if (!rawBuffer.length) return resolve({ body: null })
222
+
223
+ const contentType = (req.headers['content-type'] ?? '').toLowerCase()
224
+ const boundaryMatch = contentType.match(/multipart\/form-data;\s*boundary=(.+)/)
225
+
226
+ if (boundaryMatch) {
227
+ const boundary = (boundaryMatch[1] ?? '').trim()
228
+ const { fields, files } = this.parseMultipart(rawBuffer, boundary)
229
+ return resolve({ body: fields, files })
230
+ }
231
+
232
+ const raw = rawBuffer.toString()
233
+ try { resolve({ body: JSON.parse(raw) }) } catch { resolve({ body: raw }) }
234
+ })
235
+
236
+ req.on('error', reject)
237
+ })
238
+ }
239
+
240
+ private parseMultipart(
241
+ buffer: Buffer,
242
+ boundary: string,
243
+ ): { fields: Record<string, string>; files: Record<string, UploadedFile> } {
244
+ const fields: Record<string, string> = {}
245
+ const files: Record<string, UploadedFile> = {}
246
+
247
+ const firstDelim = Buffer.from(`--${boundary}`)
248
+ const innerDelim = Buffer.from(`\r\n--${boundary}`)
249
+ const doubleCRLF = Buffer.from('\r\n\r\n')
250
+
251
+ let pos = indexOfBuffer(buffer, firstDelim, 0)
252
+ if (pos === -1) return { fields, files }
253
+ pos += firstDelim.length
254
+
255
+ while (pos < buffer.length) {
256
+ if (buffer[pos] === 0x2d && buffer[pos + 1] === 0x2d) break
257
+ if (buffer[pos] === 0x0d && buffer[pos + 1] === 0x0a) pos += 2
258
+ else break
259
+
260
+ const headerEnd = indexOfBuffer(buffer, doubleCRLF, pos)
261
+ if (headerEnd === -1) break
262
+
263
+ const headerStr = buffer.subarray(pos, headerEnd).toString()
264
+ pos = headerEnd + 4
265
+
266
+ const nextBound = indexOfBuffer(buffer, innerDelim, pos)
267
+ if (nextBound === -1) break
268
+
269
+ const partBody = buffer.subarray(pos, nextBound)
270
+ pos = nextBound + innerDelim.length
271
+
272
+ const headers: Record<string, string> = {}
273
+ for (const line of headerStr.split('\r\n')) {
274
+ const colon = line.indexOf(':')
275
+ if (colon === -1) continue
276
+ headers[line.slice(0, colon).toLowerCase().trim()] = line.slice(colon + 1).trim()
277
+ }
278
+
279
+ const disp = headers['content-disposition'] ?? ''
280
+ const nameMatch = /name="([^"]*)"/.exec(disp)
281
+ const fileMatch = /filename="([^"]*)"/.exec(disp)
282
+ if (!nameMatch) continue
283
+
284
+ const fieldName = nameMatch[1] ?? ''
285
+
286
+ if (fileMatch) {
287
+ files[fieldName] = {
288
+ fieldName,
289
+ originalName: fileMatch[1] ?? '',
290
+ buffer: partBody,
291
+ mimeType: headers['content-type'] ?? 'application/octet-stream',
292
+ size: partBody.length,
293
+ }
294
+ } else {
295
+ fields[fieldName] = partBody.toString()
296
+ }
297
+
298
+ if (buffer[pos] === 0x2d && buffer[pos + 1] === 0x2d) break
299
+ }
300
+
301
+ return { fields, files }
302
+ }
303
+ }
@@ -0,0 +1,56 @@
1
+ export interface UploadedFile {
2
+ fieldName: string
3
+ originalName: string
4
+ buffer: Buffer
5
+ mimeType: string
6
+ size: number
7
+ }
8
+
9
+ export interface HttpRequest {
10
+ id: string
11
+ method: string
12
+ path: string
13
+ params: Record<string, string>
14
+ query: Record<string, string>
15
+ headers: Record<string, string>
16
+ body: unknown
17
+ files?: Record<string, UploadedFile>
18
+ user?: { id: string; role: string }
19
+ /** IP real del cliente extraída del socket TCP. No spoofeable. */
20
+ remoteAddress?: string
21
+ }
22
+
23
+ export interface HttpResponse {
24
+ status: number
25
+ body?: unknown
26
+ headers?: Record<string, string>
27
+ /** SSE / streaming: async generator que emite chunks de texto plano */
28
+ stream?: AsyncGenerator<string>
29
+ }
30
+
31
+ /** Helper: crea una respuesta SSE a partir de un async generator. */
32
+ export function sseResponse(
33
+ generator: AsyncGenerator<string>,
34
+ headers?: Record<string, string>,
35
+ ): HttpResponse {
36
+ return {
37
+ status: 200,
38
+ headers: {
39
+ 'Content-Type': 'text/event-stream',
40
+ 'Cache-Control': 'no-cache',
41
+ Connection: 'keep-alive',
42
+ ...headers,
43
+ },
44
+ stream: generator,
45
+ }
46
+ }
47
+
48
+ export type RouteHandler = (req: HttpRequest) => HttpResponse | Promise<HttpResponse>
49
+ export type MiddlewareHandler = (req: HttpRequest, next: () => Promise<HttpResponse>) => Promise<HttpResponse>
50
+
51
+ export interface ApiResponse<T = unknown> {
52
+ success: boolean
53
+ data: T | null
54
+ meta: { pagination?: unknown } | null
55
+ error: { code: string; message: string; details?: unknown } | null
56
+ }
@@ -0,0 +1,25 @@
1
+ // Arckode Framework — Kernel index
2
+ // Re-exporta todos los módulos del kernel. Backward compat: cualquier
3
+ // import de 'arckode-framework' sigue funcionando sin cambios.
4
+
5
+ export * from './errors'
6
+ export * from './logger'
7
+ export * from './config'
8
+ export * from './container'
9
+ export * from './cache'
10
+ export * from './validator'
11
+ export * from './auth'
12
+ export * from './seeds'
13
+
14
+ export * from './db/types'
15
+ export * from './db/orm'
16
+ export * from './db/orm-repository'
17
+ export * from './db/transactor'
18
+
19
+ export * from './http/types'
20
+ export * from './http/router'
21
+ export * from './http/server'
22
+
23
+ export * from './modules/types'
24
+ export * from './modules/create-module'
25
+ export * from './modules/system'
@@ -0,0 +1,50 @@
1
+ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
2
+
3
+ export interface LoggerTransport {
4
+ write(entry: { timestamp: string; level: LogLevel; source: string; message: string; meta?: Record<string, unknown> }): void
5
+ }
6
+
7
+ export class Logger {
8
+ constructor(
9
+ public readonly source: string = 'app',
10
+ private level: LogLevel = 'info',
11
+ private transports: LoggerTransport[] = [new ConsoleTransport()],
12
+ ) {}
13
+
14
+ child(name: string): Logger {
15
+ return new Logger(`${this.source}.${name}`, this.level, this.transports)
16
+ }
17
+
18
+ debug(message: string, meta?: Record<string, unknown>): void { this.emit('debug', message, meta) }
19
+ info(message: string, meta?: Record<string, unknown>): void { this.emit('info', message, meta) }
20
+ warn(message: string, meta?: Record<string, unknown>): void { this.emit('warn', message, meta) }
21
+ error(message: string, meta?: Record<string, unknown>): void { this.emit('error', message, meta) }
22
+
23
+ private emit(level: LogLevel, message: string, meta?: Record<string, unknown>): void {
24
+ const weight = { debug: 0, info: 1, warn: 2, error: 3 }
25
+ if (weight[level] < weight[this.level]) return
26
+
27
+ const entry: Record<string, unknown> = {
28
+ timestamp: new Date().toISOString(),
29
+ level,
30
+ source: this.source,
31
+ message,
32
+ }
33
+
34
+ if (meta && Object.keys(meta).length > 0) {
35
+ entry.meta = { ...meta }
36
+ }
37
+
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ for (const t of this.transports) t.write(entry as any)
40
+ }
41
+ }
42
+
43
+ export class ConsoleTransport implements LoggerTransport {
44
+ write(entry: { level: LogLevel; message: string; source: string } & Record<string, unknown>): void {
45
+ const line = JSON.stringify(entry)
46
+ if (entry.level === 'error') console.error(line)
47
+ else if (entry.level === 'warn') console.warn(line)
48
+ else console.log(line)
49
+ }
50
+ }
@@ -14,33 +14,42 @@ export function cors(options: {
14
14
  origins?: string[]
15
15
  methods?: string[]
16
16
  headers?: string[]
17
+ /**
18
+ * Por defecto false. Si true, envía Access-Control-Allow-Credentials: true.
19
+ * Requiere origins específicos (no '*') — los browsers rechazan la combinación.
20
+ */
17
21
  credentials?: boolean
18
22
  } = {}): MiddlewareHandler {
19
23
  const origins = options.origins ?? ['*']
20
24
  const methods = options.methods ?? ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
21
25
  const headers = options.headers ?? ['Content-Type', 'Authorization']
22
- const credentials = options.credentials ?? true
26
+ const credentials = options.credentials ?? false
23
27
 
24
28
  return async (req, next): Promise<HttpResponse> => {
29
+ const requestOrigin = req.headers['origin'] as string | undefined
30
+
31
+ // Access-Control-Allow-Origin must be a single value — never a comma-separated list.
32
+ // When multiple origins are allowed, echo back only the matching request origin.
33
+ const allowedOrigin = origins.includes('*')
34
+ ? '*'
35
+ : requestOrigin && origins.includes(requestOrigin)
36
+ ? requestOrigin
37
+ : undefined
38
+
25
39
  if (req.method === 'OPTIONS') {
26
- return {
27
- status: 204,
28
- body: null,
29
- headers: {
30
- 'Access-Control-Allow-Origin': origins.includes('*') ? '*' : origins.join(', '),
31
- 'Access-Control-Allow-Methods': methods.join(', '),
32
- 'Access-Control-Allow-Headers': headers.join(', '),
33
- 'Access-Control-Allow-Credentials': String(credentials),
34
- 'Access-Control-Max-Age': '86400',
35
- },
40
+ const h: Record<string, string> = {
41
+ 'Access-Control-Allow-Methods': methods.join(', '),
42
+ 'Access-Control-Allow-Headers': headers.join(', '),
43
+ 'Access-Control-Allow-Credentials': String(credentials),
44
+ 'Access-Control-Max-Age': '86400',
36
45
  }
46
+ if (allowedOrigin) h['Access-Control-Allow-Origin'] = allowedOrigin
47
+ return { status: 204, body: null, headers: h }
37
48
  }
38
49
 
39
50
  const res = await next()
40
- const corsHeaders: Record<string, string> = {
41
- ...res.headers,
42
- 'Access-Control-Allow-Origin': origins.includes('*') ? '*' : origins.join(', '),
43
- }
51
+ const corsHeaders: Record<string, string> = { ...res.headers }
52
+ if (allowedOrigin) corsHeaders['Access-Control-Allow-Origin'] = allowedOrigin
44
53
  if (credentials) corsHeaders['Access-Control-Allow-Credentials'] = 'true'
45
54
  return { ...res, headers: corsHeaders }
46
55
  }
@@ -56,14 +65,17 @@ export function rateLimit(opts: {
56
65
  max?: number
57
66
  /**
58
67
  * Función para derivar la clave de rate limit.
59
- * Por defecto: IP del cliente.
60
- * Para rate limit por usuario: `(req) => req.user?.id ?? req.headers['x-forwarded-for'] ?? 'anon'`
68
+ * Por defecto: IP real del socket TCP (no spoofeable).
69
+ * Si el servidor está detrás de un proxy confiable (nginx/Cloudflare), usar:
70
+ * `(req) => req.headers['x-forwarded-for']?.split(',')[0]?.trim() ?? req.remoteAddress ?? 'unknown'`
71
+ * ADVERTENCIA: x-forwarded-for es spoofeable si no validás el proxy.
72
+ * Para rate limit por usuario autenticado: `(req) => req.user?.id ?? req.remoteAddress ?? 'anon'`
61
73
  */
62
74
  keyBy?: (req: HttpRequest) => string
63
75
  } = {}): RateLimitMiddleware {
64
76
  const windowMs = opts.windowMs ?? 60000
65
77
  const max = opts.max ?? 100
66
- const keyBy = opts.keyBy ?? ((req) => (req.headers['x-forwarded-for'] ?? req.headers['host'] ?? 'unknown') as string)
78
+ const keyBy = opts.keyBy ?? ((req) => req.remoteAddress ?? 'unknown')
67
79
  const hits = new Map<string, { count: number; resetAt: number }>()
68
80
 
69
81
  const mw = async (req: HttpRequest, next: () => Promise<HttpResponse>): Promise<HttpResponse> => {
@@ -117,11 +129,16 @@ export function requestLogger(logger: { info: (msg: string, meta?: any) => void
117
129
  }
118
130
 
119
131
  // ─── Body Size Limit ───────────────────────────────────
132
+ // Nota: el límite de bytes en la wire va en NodeServer({ maxBodyBytes }).
133
+ // Este middleware aplica un límite adicional a nivel de aplicación sobre el
134
+ // body ya parseado — útil para JSON muy anidado que expande mucho en memoria.
120
135
  export function bodyLimit(maxBytes: number = 1024 * 1024): MiddlewareHandler {
121
136
  return async (req, next) => {
122
- const bodyStr = JSON.stringify(req.body ?? '')
123
- if (bodyStr.length > maxBytes) {
124
- return { status: 413, body: { error: `Request body too large. Max ${maxBytes} bytes` } }
137
+ if (req.body !== null && req.body !== undefined) {
138
+ const size = Buffer.byteLength(JSON.stringify(req.body), 'utf-8')
139
+ if (size > maxBytes) {
140
+ return { status: 413, body: { error: `Request body too large. Max ${maxBytes} bytes` } }
141
+ }
125
142
  }
126
143
  return next()
127
144
  }
@@ -0,0 +1,5 @@
1
+ import type { ModuleDefinition } from './types'
2
+
3
+ export function createModule<T>(def: ModuleDefinition<T>): ModuleDefinition<T> {
4
+ return def
5
+ }