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.
- package/adapters/jwt.ts +6 -4
- package/adapters/mysql.ts +7 -2
- package/adapters/postgres.ts +37 -0
- package/adapters/sqlite.ts +7 -1
- package/adapters/vendor.d.ts +48 -0
- package/cli/analyze/checks.ts +333 -0
- package/cli/analyze/index.ts +44 -0
- package/cli/analyze/report.ts +107 -0
- package/cli/analyze/types.ts +46 -0
- package/cli/analyze/utils.ts +36 -0
- package/cli/analyze.ts +2 -647
- package/cli/commands/db-migrate.ts +213 -89
- package/cli/commands/db-seed.ts +97 -32
- package/cli/commands/db-utils.ts +192 -0
- package/cli/commands/new.ts +175 -0
- package/cli/commands/routes.ts +94 -0
- package/cli/index.ts +57 -404
- package/cli/stubs/claude-md-stub.ts +21 -8
- package/cli/stubs/module/core.ts +162 -0
- package/cli/stubs/module/data.ts +171 -0
- package/cli/stubs/module/index.ts +5 -0
- package/cli/stubs/module/service.ts +198 -0
- package/cli/stubs/module/types.ts +12 -0
- package/cli/stubs/module-stub.ts +2 -552
- package/kernel/auth.ts +114 -0
- package/kernel/cache.ts +37 -0
- package/kernel/config.ts +129 -0
- package/kernel/container.ts +64 -0
- package/kernel/db/orm-migrate.ts +136 -0
- package/kernel/db/orm-repository.ts +45 -0
- package/kernel/db/orm-utils.ts +93 -0
- package/kernel/db/orm.ts +254 -0
- package/kernel/db/transactor.ts +17 -0
- package/kernel/db/types.ts +72 -0
- package/kernel/errors.ts +102 -0
- package/kernel/framework.default.ts +41 -0
- package/kernel/framework.ts +8 -2144
- package/kernel/http/router.ts +131 -0
- package/kernel/http/server.ts +303 -0
- package/kernel/http/types.ts +56 -0
- package/kernel/index.ts +25 -0
- package/kernel/logger.ts +50 -0
- package/kernel/middlewares.ts +38 -21
- package/kernel/modules/create-module.ts +5 -0
- package/kernel/modules/system.ts +149 -0
- package/kernel/modules/types.ts +46 -0
- package/kernel/seeds.ts +48 -0
- package/kernel/static.ts +11 -2
- package/kernel/testing.ts +8 -3
- package/kernel/validator.ts +116 -0
- package/modules/events/index.ts +19 -3
- package/modules/mail/index.ts +14 -2
- package/modules/storage/local-adapter.ts +19 -5
- package/modules/ws/index.ts +123 -18
- package/package.json +8 -11
- package/skills/auth/SKILL.md +36 -220
- package/skills/cli/SKILL.md +32 -251
- package/skills/config/SKILL.md +30 -239
- package/skills/connectors/SKILL.md +32 -295
- package/skills/helpers/SKILL.md +26 -195
- package/skills/middlewares/SKILL.md +30 -267
- package/skills/orm/SKILL.md +42 -349
- package/skills/realtime/SKILL.md +22 -297
- package/skills/services/SKILL.md +40 -183
- 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
|
+
}
|
package/kernel/index.ts
ADDED
|
@@ -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'
|
package/kernel/logger.ts
ADDED
|
@@ -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
|
+
}
|
package/kernel/middlewares.ts
CHANGED
|
@@ -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 ??
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
60
|
-
*
|
|
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) =>
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
}
|