arckode-framework 1.0.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/README.md +546 -0
- package/adapters/__tests__/mysql.test.ts +283 -0
- package/adapters/jwt.ts +18 -0
- package/adapters/mysql.ts +98 -0
- package/adapters/postgres.ts +52 -0
- package/adapters/redis-cache.ts +64 -0
- package/adapters/sqlite.ts +73 -0
- package/adapters/vendor.d.ts +48 -0
- package/bin/arckode.js +7 -0
- package/cli/analyze.ts +506 -0
- package/cli/commands/db-migrate.ts +121 -0
- package/cli/commands/db-seed.ts +54 -0
- package/cli/commands/generate-api-client.ts +106 -0
- package/cli/commands/make-adapter.ts +132 -0
- package/cli/commands/make-auth.ts +297 -0
- package/cli/commands/make-frontend-module.ts +271 -0
- package/cli/commands/make-helper.ts +65 -0
- package/cli/commands/make-migration.ts +30 -0
- package/cli/commands/make-seed.ts +29 -0
- package/cli/generate.ts +132 -0
- package/cli/index.ts +604 -0
- package/cli/stubs/frontend-stub.ts +294 -0
- package/cli/stubs/fullstack-stub.ts +46 -0
- package/cli/stubs/module-stub.ts +469 -0
- package/kernel/__tests__/adapters.test.ts +101 -0
- package/kernel/__tests__/analyzer.test.ts +282 -0
- package/kernel/__tests__/framework.test.ts +617 -0
- package/kernel/__tests__/middlewares.test.ts +174 -0
- package/kernel/__tests__/static.test.ts +94 -0
- package/kernel/framework.ts +1851 -0
- package/kernel/middlewares.ts +179 -0
- package/kernel/static.ts +76 -0
- package/kernel/testing.ts +237 -0
- package/modules/events/index.ts +99 -0
- package/modules/mail/index.ts +51 -0
- package/modules/mail/smtp-adapter.ts +42 -0
- package/modules/queue/index.ts +78 -0
- package/modules/storage/index.ts +40 -0
- package/modules/storage/local-adapter.ts +41 -0
- package/modules/ws/__tests__/ws.test.ts +114 -0
- package/modules/ws/index.ts +136 -0
- package/package.json +99 -0
- package/skills/auth/SKILL.md +243 -0
- package/skills/cli/SKILL.md +258 -0
- package/skills/config/SKILL.md +253 -0
- package/skills/connectors/SKILL.md +259 -0
- package/skills/helpers/SKILL.md +206 -0
- package/skills/middlewares/SKILL.md +282 -0
- package/skills/orm/SKILL.md +260 -0
- package/skills/realtime/SKILL.md +307 -0
- package/skills/services/SKILL.md +206 -0
- package/skills/testing/SKILL.md +257 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// kernel/middlewares.ts — Middlewares comunes
|
|
2
|
+
// Cada middleware es una función pura: (req, next) => Response
|
|
3
|
+
// Se registran en el Router: router.use(mw) o router.get('/', h, [mw1, mw2])
|
|
4
|
+
|
|
5
|
+
import { gzip } from 'node:zlib'
|
|
6
|
+
import { promisify } from 'node:util'
|
|
7
|
+
import type { MiddlewareHandler, HttpRequest, HttpResponse } from './framework'
|
|
8
|
+
import { AuthError, RateLimitError } from './framework'
|
|
9
|
+
|
|
10
|
+
const gzipAsync = promisify(gzip)
|
|
11
|
+
|
|
12
|
+
// ─── CORS ──────────────────────────────────────────────
|
|
13
|
+
export function cors(options: {
|
|
14
|
+
origins?: string[]
|
|
15
|
+
methods?: string[]
|
|
16
|
+
headers?: string[]
|
|
17
|
+
credentials?: boolean
|
|
18
|
+
} = {}): MiddlewareHandler {
|
|
19
|
+
const origins = options.origins ?? ['*']
|
|
20
|
+
const methods = options.methods ?? ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
|
|
21
|
+
const headers = options.headers ?? ['Content-Type', 'Authorization']
|
|
22
|
+
const credentials = options.credentials ?? true
|
|
23
|
+
|
|
24
|
+
return async (req, next): Promise<HttpResponse> => {
|
|
25
|
+
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
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const res = await next()
|
|
40
|
+
const corsHeaders: Record<string, string> = {
|
|
41
|
+
...res.headers,
|
|
42
|
+
'Access-Control-Allow-Origin': origins.includes('*') ? '*' : origins.join(', '),
|
|
43
|
+
}
|
|
44
|
+
if (credentials) corsHeaders['Access-Control-Allow-Credentials'] = 'true'
|
|
45
|
+
return { ...res, headers: corsHeaders }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Rate Limiting (en memoria) ────────────────────────
|
|
50
|
+
export interface RateLimitMiddleware extends MiddlewareHandler {
|
|
51
|
+
reset(key: string): void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function rateLimit(opts: {
|
|
55
|
+
windowMs?: number
|
|
56
|
+
max?: number
|
|
57
|
+
/**
|
|
58
|
+
* 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'`
|
|
61
|
+
*/
|
|
62
|
+
keyBy?: (req: HttpRequest) => string
|
|
63
|
+
} = {}): RateLimitMiddleware {
|
|
64
|
+
const windowMs = opts.windowMs ?? 60000
|
|
65
|
+
const max = opts.max ?? 100
|
|
66
|
+
const keyBy = opts.keyBy ?? ((req) => (req.headers['x-forwarded-for'] ?? req.headers['host'] ?? 'unknown') as string)
|
|
67
|
+
const hits = new Map<string, { count: number; resetAt: number }>()
|
|
68
|
+
|
|
69
|
+
const mw = async (req: HttpRequest, next: () => Promise<HttpResponse>): Promise<HttpResponse> => {
|
|
70
|
+
const key = keyBy(req)
|
|
71
|
+
const now = Date.now()
|
|
72
|
+
const record = hits.get(key)
|
|
73
|
+
|
|
74
|
+
if (!record || now > record.resetAt) {
|
|
75
|
+
hits.set(key, { count: 1, resetAt: now + windowMs })
|
|
76
|
+
return next()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (record.count >= max) {
|
|
80
|
+
throw new RateLimitError(`Too many requests. Max ${max} per ${windowMs / 1000}s`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
record.count++
|
|
84
|
+
return next()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const rlMw = mw as RateLimitMiddleware
|
|
88
|
+
rlMw.reset = (key: string) => { hits.delete(key) }
|
|
89
|
+
return rlMw
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Request Logger ────────────────────────────────────
|
|
93
|
+
export function requestLogger(logger: { info: (msg: string, meta?: any) => void }): MiddlewareHandler {
|
|
94
|
+
return async (req, next) => {
|
|
95
|
+
const start = Date.now()
|
|
96
|
+
logger.info(`${req.method} ${req.path}`, { requestId: req.id })
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const res = await next()
|
|
100
|
+
const duration = Date.now() - start
|
|
101
|
+
logger.info(`${req.method} ${req.path} ${res.status} ${duration}ms`, {
|
|
102
|
+
requestId: req.id,
|
|
103
|
+
status: res.status,
|
|
104
|
+
duration,
|
|
105
|
+
})
|
|
106
|
+
return res
|
|
107
|
+
} catch (error) {
|
|
108
|
+
const duration = Date.now() - start
|
|
109
|
+
logger.info(`${req.method} ${req.path} ERROR ${duration}ms`, {
|
|
110
|
+
requestId: req.id,
|
|
111
|
+
duration,
|
|
112
|
+
error: String(error),
|
|
113
|
+
})
|
|
114
|
+
throw error
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── Body Size Limit ───────────────────────────────────
|
|
120
|
+
export function bodyLimit(maxBytes: number = 1024 * 1024): MiddlewareHandler {
|
|
121
|
+
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` } }
|
|
125
|
+
}
|
|
126
|
+
return next()
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── Timeout ───────────────────────────────────────────
|
|
131
|
+
export function timeout(ms: number = 5000): MiddlewareHandler {
|
|
132
|
+
return async (req, next) => {
|
|
133
|
+
const result = await Promise.race([
|
|
134
|
+
next(),
|
|
135
|
+
new Promise<never>((_, reject) =>
|
|
136
|
+
setTimeout(() => reject(new Error(`Request timed out after ${ms}ms`)), ms)
|
|
137
|
+
),
|
|
138
|
+
])
|
|
139
|
+
return result
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Require Auth (wrapper alrededor de auth.authenticate) ──
|
|
144
|
+
export function requireAuth(authenticate: (...roles: string[]) => MiddlewareHandler, ...roles: string[]): MiddlewareHandler {
|
|
145
|
+
return authenticate(...roles)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Compression (gzip) ────────────────────────────────
|
|
149
|
+
// Comprime la respuesta JSON si el cliente acepta gzip.
|
|
150
|
+
// Solo aplica a respuestas JSON (no a SSE/streams).
|
|
151
|
+
export function compression(opts: { threshold?: number } = {}): MiddlewareHandler {
|
|
152
|
+
const threshold = opts.threshold ?? 1024 // solo comprimir si supera 1KB
|
|
153
|
+
|
|
154
|
+
return async (req, next) => {
|
|
155
|
+
const res = await next()
|
|
156
|
+
|
|
157
|
+
// No comprimir streams SSE ni responses sin body
|
|
158
|
+
if (res.stream || res.body === null || res.body === undefined) return res
|
|
159
|
+
|
|
160
|
+
const acceptEncoding = req.headers['accept-encoding'] ?? ''
|
|
161
|
+
if (!acceptEncoding.includes('gzip')) return res
|
|
162
|
+
|
|
163
|
+
const bodyStr = JSON.stringify(res.body)
|
|
164
|
+
if (bodyStr.length < threshold) return res
|
|
165
|
+
|
|
166
|
+
const compressed = await gzipAsync(bodyStr)
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
...res,
|
|
170
|
+
body: compressed,
|
|
171
|
+
headers: {
|
|
172
|
+
...res.headers,
|
|
173
|
+
'Content-Encoding': 'gzip',
|
|
174
|
+
'Content-Type': 'application/json',
|
|
175
|
+
'Content-Length': String(compressed.length),
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
package/kernel/static.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// kernel/static.ts — Servidor de archivos estáticos
|
|
2
|
+
// Para modo monolito: sirve el frontend compilado desde el mismo servidor
|
|
3
|
+
// SOLID: responsabilidad ÚNICA de servir archivos estáticos
|
|
4
|
+
|
|
5
|
+
import { readFile, access } from 'node:fs/promises'
|
|
6
|
+
import { join, extname } from 'node:path'
|
|
7
|
+
import type { Router, MiddlewareHandler } from './framework'
|
|
8
|
+
|
|
9
|
+
export interface StaticOptions {
|
|
10
|
+
prefix?: string
|
|
11
|
+
fallback?: string
|
|
12
|
+
cacheControl?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const MIME_TYPES: Record<string, string> = {
|
|
16
|
+
'.html': 'text/html',
|
|
17
|
+
'.css': 'text/css',
|
|
18
|
+
'.js': 'application/javascript',
|
|
19
|
+
'.ts': 'application/javascript',
|
|
20
|
+
'.json': 'application/json',
|
|
21
|
+
'.png': 'image/png',
|
|
22
|
+
'.jpg': 'image/jpeg',
|
|
23
|
+
'.jpeg': 'image/jpeg',
|
|
24
|
+
'.gif': 'image/gif',
|
|
25
|
+
'.svg': 'image/svg+xml',
|
|
26
|
+
'.ico': 'image/x-icon',
|
|
27
|
+
'.woff': 'font/woff',
|
|
28
|
+
'.woff2': 'font/woff2',
|
|
29
|
+
'.webp': 'image/webp',
|
|
30
|
+
'.pdf': 'application/pdf',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function serveStatic(
|
|
34
|
+
router: Router,
|
|
35
|
+
basePath: string,
|
|
36
|
+
options: StaticOptions = {},
|
|
37
|
+
): void {
|
|
38
|
+
const { prefix = '', fallback, cacheControl = 'public, max-age=3600' } = options
|
|
39
|
+
|
|
40
|
+
// GET /* → intenta servir archivo estático
|
|
41
|
+
router.get(`${prefix}/:path(*)`, async (req) => {
|
|
42
|
+
const filePath = join(basePath, req.params['path'] || 'index.html')
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
await access(filePath)
|
|
46
|
+
const content = await readFile(filePath)
|
|
47
|
+
const ext = extname(filePath).toLowerCase()
|
|
48
|
+
const mime = MIME_TYPES[ext] ?? 'application/octet-stream'
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
status: 200,
|
|
52
|
+
body: content,
|
|
53
|
+
headers: {
|
|
54
|
+
'Content-Type': mime,
|
|
55
|
+
'Cache-Control': cacheControl,
|
|
56
|
+
} satisfies Record<string, string>,
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// Si no existe y hay fallback, servir el fallback (para SPA routing)
|
|
60
|
+
if (fallback) {
|
|
61
|
+
const fallbackPath = join(basePath, fallback)
|
|
62
|
+
try {
|
|
63
|
+
const content = await readFile(fallbackPath)
|
|
64
|
+
return {
|
|
65
|
+
status: 200,
|
|
66
|
+
body: content,
|
|
67
|
+
headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache' } satisfies Record<string, string>,
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
return { status: 404, body: { error: 'File not found' } }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { status: 404, body: { error: 'File not found' } }
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// kernel/testing.ts — Utilidades de testing para módulos Arckode
|
|
2
|
+
// Uso: import { createTestClient, createRecordingOrm } from 'arckode-framework/testing'
|
|
3
|
+
//
|
|
4
|
+
// Diseño: sin dependencias externas, compatible con bun:test, Jest y Vitest.
|
|
5
|
+
|
|
6
|
+
import { request as httpRequest } from 'node:http'
|
|
7
|
+
import type { HttpRequest, HttpResponse, MiddlewareHandler } from './framework'
|
|
8
|
+
import { Router, ORM, Logger, NodeServer } from './framework'
|
|
9
|
+
import type { DbAdapter } from './framework'
|
|
10
|
+
|
|
11
|
+
// ═══════════════════════════════════════════════════════════════
|
|
12
|
+
// TEST CLIENT — hace requests a un Router sin levantar HTTP
|
|
13
|
+
// ═══════════════════════════════════════════════════════════════
|
|
14
|
+
|
|
15
|
+
export interface TestRequestOptions {
|
|
16
|
+
headers?: Record<string, string>
|
|
17
|
+
body?: unknown
|
|
18
|
+
query?: Record<string, string>
|
|
19
|
+
user?: { id: string; role: string }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TestClient {
|
|
23
|
+
get(path: string, opts?: TestRequestOptions): Promise<HttpResponse>
|
|
24
|
+
post(path: string, opts?: TestRequestOptions): Promise<HttpResponse>
|
|
25
|
+
put(path: string, opts?: TestRequestOptions): Promise<HttpResponse>
|
|
26
|
+
patch(path: string, opts?: TestRequestOptions): Promise<HttpResponse>
|
|
27
|
+
delete(path: string, opts?: TestRequestOptions): Promise<HttpResponse>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Crea un cliente de test que dispara requests directamente al Router.
|
|
32
|
+
* No levanta ningún servidor HTTP.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* const client = createTestClient(router)
|
|
36
|
+
* const res = await client.get('/productos')
|
|
37
|
+
* expect(res.status).toBe(200)
|
|
38
|
+
*/
|
|
39
|
+
export function createTestClient(router: Router): TestClient {
|
|
40
|
+
const request = (method: string) => async (path: string, opts: TestRequestOptions = {}): Promise<HttpResponse> => {
|
|
41
|
+
return router.resolve(method, path, {
|
|
42
|
+
query: opts.query ?? {},
|
|
43
|
+
headers: opts.headers ?? {},
|
|
44
|
+
body: opts.body ?? null,
|
|
45
|
+
user: opts.user,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
get: request('GET'),
|
|
51
|
+
post: request('POST'),
|
|
52
|
+
put: request('PUT'),
|
|
53
|
+
patch: request('PATCH'),
|
|
54
|
+
delete: request('DELETE'),
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ═══════════════════════════════════════════════════════════════
|
|
59
|
+
// RECORDING ORM — captura SQL sin tocar ninguna BD real
|
|
60
|
+
// ═══════════════════════════════════════════════════════════════
|
|
61
|
+
|
|
62
|
+
export interface RecordedCall {
|
|
63
|
+
sql: string
|
|
64
|
+
params: unknown[]
|
|
65
|
+
type: 'query' | 'run'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface RecordingDb extends DbAdapter {
|
|
69
|
+
calls: RecordedCall[]
|
|
70
|
+
lastSql(): string
|
|
71
|
+
lastParams(): unknown[]
|
|
72
|
+
reset(): void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Adapter de BD que registra las llamadas SQL en vez de ejecutarlas.
|
|
77
|
+
* Ideal para verificar qué SQL genera el ORM sin depender de SQLite/PostgreSQL.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* const db = createRecordingDb({ queryData: [{ id: '1', nombre: 'Laptop' }] })
|
|
81
|
+
* const orm = new ORM(db)
|
|
82
|
+
* await orm.findMany('Producto', { activo: true })
|
|
83
|
+
* expect(db.lastSql()).toContain('WHERE activo = ?')
|
|
84
|
+
*/
|
|
85
|
+
export function createRecordingDb(opts: {
|
|
86
|
+
countValue?: number
|
|
87
|
+
queryData?: unknown[]
|
|
88
|
+
} = {}): RecordingDb {
|
|
89
|
+
const calls: RecordedCall[] = []
|
|
90
|
+
|
|
91
|
+
const db: RecordingDb = {
|
|
92
|
+
calls,
|
|
93
|
+
lastSql: () => calls.at(-1)?.sql ?? '',
|
|
94
|
+
lastParams: () => calls.at(-1)?.params ?? [],
|
|
95
|
+
reset: () => calls.splice(0, calls.length),
|
|
96
|
+
|
|
97
|
+
async query(sql: string, params: unknown[] = []) {
|
|
98
|
+
calls.push({ sql, params, type: 'query' })
|
|
99
|
+
if (sql.includes('COUNT(*)')) return [{ n: opts.countValue ?? 0 }]
|
|
100
|
+
return opts.queryData ?? []
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
async run(sql: string, params: unknown[] = []) {
|
|
104
|
+
calls.push({ sql, params, type: 'run' })
|
|
105
|
+
return { changes: 1, lastId: crypto.randomUUID() }
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async close() {},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return db
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ═══════════════════════════════════════════════════════════════
|
|
115
|
+
// AUTH MOCK — simula tokens sin necesitar JWT real
|
|
116
|
+
// ═══════════════════════════════════════════════════════════════
|
|
117
|
+
|
|
118
|
+
export interface MockAuthOptions {
|
|
119
|
+
user?: { id: string; role: string }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Middleware que simula autenticación en tests.
|
|
124
|
+
* Inyecta `req.user` directamente sin verificar JWT.
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* router.get('/perfil', handler, [mockAuth({ user: { id: '1', role: 'admin' } })])
|
|
128
|
+
* const res = await client.get('/perfil')
|
|
129
|
+
*/
|
|
130
|
+
export function mockAuth(opts: MockAuthOptions = {}): MiddlewareHandler {
|
|
131
|
+
const user = opts.user ?? { id: 'test-user-id', role: 'user' }
|
|
132
|
+
return async (req, next) => {
|
|
133
|
+
req.user = user
|
|
134
|
+
return next()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ═══════════════════════════════════════════════════════════════
|
|
139
|
+
// LOGGER SILENCIOSO — para que los tests no llenen la consola
|
|
140
|
+
// ═══════════════════════════════════════════════════════════════
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Logger que no imprime nada. Úsalo en tests para silenciar output.
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* const logger = silentLogger()
|
|
147
|
+
* const service = new MiService(orm, logger)
|
|
148
|
+
*/
|
|
149
|
+
export function silentLogger(): Logger {
|
|
150
|
+
return new Logger('test', 'error')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ═══════════════════════════════════════════════════════════════
|
|
154
|
+
// INTEGRATION CLIENT — levanta HTTP real, prueba la pila completa
|
|
155
|
+
// ═══════════════════════════════════════════════════════════════
|
|
156
|
+
|
|
157
|
+
export interface IntegrationClient {
|
|
158
|
+
get(path: string, opts?: { headers?: Record<string, string>; query?: Record<string, string> }): Promise<{ status: number; body: unknown; headers: Record<string, string> }>
|
|
159
|
+
post(path: string, body?: unknown, opts?: { headers?: Record<string, string> }): Promise<{ status: number; body: unknown; headers: Record<string, string> }>
|
|
160
|
+
put(path: string, body?: unknown, opts?: { headers?: Record<string, string> }): Promise<{ status: number; body: unknown; headers: Record<string, string> }>
|
|
161
|
+
patch(path: string, body?: unknown, opts?: { headers?: Record<string, string> }): Promise<{ status: number; body: unknown; headers: Record<string, string> }>
|
|
162
|
+
delete(path: string, opts?: { headers?: Record<string, string> }): Promise<{ status: number; body: unknown; headers: Record<string, string> }>
|
|
163
|
+
stop(): Promise<void>
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Levanta un servidor HTTP real en un puerto aleatorio y retorna un cliente HTTP.
|
|
168
|
+
* Prueba la pila completa: parsing del body, headers reales, middlewares, SSE, bodyLimit.
|
|
169
|
+
* Llama a `stop()` en el afterAll/teardown de tu suite.
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* const app = await createIntegrationClient(router)
|
|
173
|
+
* const res = await app.post('/productos', { nombre: 'Laptop', precio: 999 })
|
|
174
|
+
* expect(res.status).toBe(201)
|
|
175
|
+
* await app.stop()
|
|
176
|
+
*/
|
|
177
|
+
export async function createIntegrationClient(router: Router): Promise<IntegrationClient> {
|
|
178
|
+
const logger = silentLogger()
|
|
179
|
+
// Puerto 0 = SO asigna uno libre automáticamente
|
|
180
|
+
const server = new NodeServer(0, logger)
|
|
181
|
+
await server.start((req) => router.resolve(req.method, req.path, req))
|
|
182
|
+
const actualPort = server.getPort()
|
|
183
|
+
|
|
184
|
+
const makeRequest = (
|
|
185
|
+
method: string,
|
|
186
|
+
path: string,
|
|
187
|
+
body?: unknown,
|
|
188
|
+
extraHeaders?: Record<string, string>,
|
|
189
|
+
query?: Record<string, string>,
|
|
190
|
+
): Promise<{ status: number; body: unknown; headers: Record<string, string> }> => {
|
|
191
|
+
return new Promise((resolve, reject) => {
|
|
192
|
+
const qs = query && Object.keys(query).length > 0
|
|
193
|
+
? '?' + new URLSearchParams(query).toString()
|
|
194
|
+
: ''
|
|
195
|
+
|
|
196
|
+
const bodyStr = body !== undefined ? JSON.stringify(body) : undefined
|
|
197
|
+
|
|
198
|
+
const req = httpRequest({
|
|
199
|
+
hostname: '127.0.0.1',
|
|
200
|
+
port: actualPort,
|
|
201
|
+
path: path + qs,
|
|
202
|
+
method,
|
|
203
|
+
headers: {
|
|
204
|
+
'Content-Type': 'application/json',
|
|
205
|
+
...(bodyStr ? { 'Content-Length': String(Buffer.byteLength(bodyStr)) } : {}),
|
|
206
|
+
...extraHeaders,
|
|
207
|
+
},
|
|
208
|
+
}, (res) => {
|
|
209
|
+
const chunks: Buffer[] = []
|
|
210
|
+
res.on('data', (c: Buffer) => chunks.push(c))
|
|
211
|
+
res.on('end', () => {
|
|
212
|
+
const raw = Buffer.concat(chunks).toString()
|
|
213
|
+
let parsed: unknown = raw
|
|
214
|
+
try { parsed = JSON.parse(raw) } catch { /* texto plano */ }
|
|
215
|
+
resolve({
|
|
216
|
+
status: res.statusCode ?? 0,
|
|
217
|
+
body: parsed,
|
|
218
|
+
headers: res.headers as Record<string, string>,
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
req.on('error', reject)
|
|
224
|
+
if (bodyStr) req.write(bodyStr)
|
|
225
|
+
req.end()
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
get: (path, opts) => makeRequest('GET', path, undefined, opts?.headers, opts?.query),
|
|
231
|
+
post: (path, body, opts) => makeRequest('POST', path, body, opts?.headers),
|
|
232
|
+
put: (path, body, opts) => makeRequest('PUT', path, body, opts?.headers),
|
|
233
|
+
patch: (path, body, opts) => makeRequest('PATCH', path, body, opts?.headers),
|
|
234
|
+
delete: (path, opts) => makeRequest('DELETE', path, undefined, opts?.headers),
|
|
235
|
+
stop: () => server.stop(),
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// modules/events/index.ts — Bus de eventos (pub-sub) formal
|
|
2
|
+
// Para comunicación entre módulos sin acoplamiento directo
|
|
3
|
+
// Complementa los sockets: sockets son 1:1, events es 1:N
|
|
4
|
+
|
|
5
|
+
export interface EventMessage {
|
|
6
|
+
name: string
|
|
7
|
+
data: unknown
|
|
8
|
+
source: string // módulo que emitió
|
|
9
|
+
timestamp: string
|
|
10
|
+
id: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type EventHandler = (event: EventMessage) => Promise<void>
|
|
14
|
+
|
|
15
|
+
export interface EventBusAdapter {
|
|
16
|
+
publish(name: string, data: unknown, source: string): Promise<void>
|
|
17
|
+
subscribe(name: string, handler: EventHandler): void
|
|
18
|
+
unsubscribe(name: string, handler: EventHandler): void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class EventBus {
|
|
22
|
+
private handlers = new Map<string, Set<EventHandler>>()
|
|
23
|
+
private history: EventMessage[] = []
|
|
24
|
+
private maxHistory = 100
|
|
25
|
+
|
|
26
|
+
constructor(private adapter?: EventBusAdapter) {}
|
|
27
|
+
|
|
28
|
+
async emit(name: string, data: unknown, source: string = 'system'): Promise<void> {
|
|
29
|
+
const event: EventMessage = {
|
|
30
|
+
id: crypto.randomUUID(),
|
|
31
|
+
name,
|
|
32
|
+
data,
|
|
33
|
+
source,
|
|
34
|
+
timestamp: new Date().toISOString(),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Guardar historial (para debug y recovery)
|
|
38
|
+
this.history.push(event)
|
|
39
|
+
if (this.history.length > this.maxHistory) this.history.shift()
|
|
40
|
+
|
|
41
|
+
// Handler locales
|
|
42
|
+
const localHandlers = this.handlers.get(name)
|
|
43
|
+
if (localHandlers) {
|
|
44
|
+
await Promise.allSettled([...localHandlers].map(h => h(event).catch(e => {
|
|
45
|
+
console.error(`[EventBus] Error en handler para "${name}":`, e)
|
|
46
|
+
})))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Adapter externo (Redis, etc.)
|
|
50
|
+
if (this.adapter) {
|
|
51
|
+
await this.adapter.publish(name, data, source)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
on(name: string, handler: EventHandler): void {
|
|
56
|
+
if (!this.handlers.has(name)) this.handlers.set(name, new Set())
|
|
57
|
+
this.handlers.get(name)!.add(handler)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
off(name: string, handler: EventHandler): void {
|
|
61
|
+
this.handlers.get(name)?.delete(handler)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
once(name: string, handler: EventHandler): void {
|
|
65
|
+
const wrapper: EventHandler = async (event) => {
|
|
66
|
+
await handler(event)
|
|
67
|
+
this.off(name, wrapper)
|
|
68
|
+
}
|
|
69
|
+
this.on(name, wrapper)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
getHistory(name?: string): EventMessage[] {
|
|
73
|
+
if (name) return this.history.filter(e => e.name === name)
|
|
74
|
+
return [...this.history]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
clearHistory(): void {
|
|
78
|
+
this.history = []
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get listeners(): Record<string, number> {
|
|
82
|
+
const result: Record<string, number> = {}
|
|
83
|
+
for (const [name, handlers] of this.handlers) {
|
|
84
|
+
result[name] = handlers.size
|
|
85
|
+
}
|
|
86
|
+
return result
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── Uso en composition root ───────────────────────────
|
|
91
|
+
// const events = new EventBus()
|
|
92
|
+
//
|
|
93
|
+
// // Módulo A emite
|
|
94
|
+
// await events.emit('pedido.creado', { id: '123' }, 'pedidos')
|
|
95
|
+
//
|
|
96
|
+
// // Módulo B escucha
|
|
97
|
+
// events.on('pedido.creado', async (event) => {
|
|
98
|
+
// await descontarStock(event.data)
|
|
99
|
+
// })
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// modules/mail/index.ts — Mail module
|
|
2
|
+
// Sends emails via adapter (SMTP, SendGrid, etc.). The module is adapter-agnostic.
|
|
3
|
+
|
|
4
|
+
export interface MailAddress { name?: string; address: string }
|
|
5
|
+
export interface MailAttachment { filename: string; content: Buffer | string; contentType?: string }
|
|
6
|
+
|
|
7
|
+
export interface MailMessage {
|
|
8
|
+
to: MailAddress | MailAddress[]
|
|
9
|
+
subject: string
|
|
10
|
+
text?: string
|
|
11
|
+
html?: string
|
|
12
|
+
from?: MailAddress
|
|
13
|
+
cc?: MailAddress | MailAddress[]
|
|
14
|
+
bcc?: MailAddress | MailAddress[]
|
|
15
|
+
attachments?: MailAttachment[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MailAdapter {
|
|
19
|
+
send(message: MailMessage): Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class MailService {
|
|
23
|
+
constructor(
|
|
24
|
+
private adapter: MailAdapter,
|
|
25
|
+
private defaultFrom: MailAddress = { address: 'noreply@arckode.app' },
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
async send(message: MailMessage): Promise<void> {
|
|
29
|
+
const msg: MailMessage = {
|
|
30
|
+
...message,
|
|
31
|
+
from: message.from ?? this.defaultFrom,
|
|
32
|
+
}
|
|
33
|
+
await this.adapter.send(msg)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async sendWelcome(email: string, name: string): Promise<void> {
|
|
37
|
+
await this.send({
|
|
38
|
+
to: { address: email, name },
|
|
39
|
+
subject: 'Welcome',
|
|
40
|
+
html: `<h1>Welcome, ${name}!</h1><p>Thanks for signing up.</p>`,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async sendPasswordReset(email: string, token: string): Promise<void> {
|
|
45
|
+
await this.send({
|
|
46
|
+
to: { address: email },
|
|
47
|
+
subject: 'Password reset',
|
|
48
|
+
html: `<p>Use this token to reset your password: <b>${token}</b></p>`,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// modules/mail/smtp-adapter.ts — Adapter SMTP para MailService
|
|
2
|
+
// npm: nodemailer
|
|
3
|
+
|
|
4
|
+
import nodemailer from 'nodemailer'
|
|
5
|
+
import type { MailAdapter, MailMessage } from './index'
|
|
6
|
+
|
|
7
|
+
export interface SmtpConfig {
|
|
8
|
+
host: string
|
|
9
|
+
port: number
|
|
10
|
+
user: string
|
|
11
|
+
pass: string
|
|
12
|
+
secure?: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class SmtpAdapter implements MailAdapter {
|
|
16
|
+
private transporter: nodemailer.Transporter
|
|
17
|
+
|
|
18
|
+
constructor(config: SmtpConfig) {
|
|
19
|
+
this.transporter = nodemailer.createTransport({
|
|
20
|
+
host: config.host,
|
|
21
|
+
port: config.port,
|
|
22
|
+
secure: config.secure ?? config.port === 465,
|
|
23
|
+
auth: { user: config.user, pass: config.pass },
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async send(message: MailMessage): Promise<void> {
|
|
28
|
+
const to = Array.isArray(message.to) ? message.to.map(a => a.address).join(', ') : message.to.address
|
|
29
|
+
await this.transporter.sendMail({
|
|
30
|
+
from: message.from ? `"${message.from.name}" <${message.from.address}>` : undefined,
|
|
31
|
+
to,
|
|
32
|
+
subject: message.subject,
|
|
33
|
+
text: message.text,
|
|
34
|
+
html: message.html,
|
|
35
|
+
attachments: message.attachments?.map(a => ({
|
|
36
|
+
filename: a.filename,
|
|
37
|
+
content: a.content,
|
|
38
|
+
contentType: a.contentType,
|
|
39
|
+
})),
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
}
|