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.
Files changed (52) hide show
  1. package/README.md +546 -0
  2. package/adapters/__tests__/mysql.test.ts +283 -0
  3. package/adapters/jwt.ts +18 -0
  4. package/adapters/mysql.ts +98 -0
  5. package/adapters/postgres.ts +52 -0
  6. package/adapters/redis-cache.ts +64 -0
  7. package/adapters/sqlite.ts +73 -0
  8. package/adapters/vendor.d.ts +48 -0
  9. package/bin/arckode.js +7 -0
  10. package/cli/analyze.ts +506 -0
  11. package/cli/commands/db-migrate.ts +121 -0
  12. package/cli/commands/db-seed.ts +54 -0
  13. package/cli/commands/generate-api-client.ts +106 -0
  14. package/cli/commands/make-adapter.ts +132 -0
  15. package/cli/commands/make-auth.ts +297 -0
  16. package/cli/commands/make-frontend-module.ts +271 -0
  17. package/cli/commands/make-helper.ts +65 -0
  18. package/cli/commands/make-migration.ts +30 -0
  19. package/cli/commands/make-seed.ts +29 -0
  20. package/cli/generate.ts +132 -0
  21. package/cli/index.ts +604 -0
  22. package/cli/stubs/frontend-stub.ts +294 -0
  23. package/cli/stubs/fullstack-stub.ts +46 -0
  24. package/cli/stubs/module-stub.ts +469 -0
  25. package/kernel/__tests__/adapters.test.ts +101 -0
  26. package/kernel/__tests__/analyzer.test.ts +282 -0
  27. package/kernel/__tests__/framework.test.ts +617 -0
  28. package/kernel/__tests__/middlewares.test.ts +174 -0
  29. package/kernel/__tests__/static.test.ts +94 -0
  30. package/kernel/framework.ts +1851 -0
  31. package/kernel/middlewares.ts +179 -0
  32. package/kernel/static.ts +76 -0
  33. package/kernel/testing.ts +237 -0
  34. package/modules/events/index.ts +99 -0
  35. package/modules/mail/index.ts +51 -0
  36. package/modules/mail/smtp-adapter.ts +42 -0
  37. package/modules/queue/index.ts +78 -0
  38. package/modules/storage/index.ts +40 -0
  39. package/modules/storage/local-adapter.ts +41 -0
  40. package/modules/ws/__tests__/ws.test.ts +114 -0
  41. package/modules/ws/index.ts +136 -0
  42. package/package.json +99 -0
  43. package/skills/auth/SKILL.md +243 -0
  44. package/skills/cli/SKILL.md +258 -0
  45. package/skills/config/SKILL.md +253 -0
  46. package/skills/connectors/SKILL.md +259 -0
  47. package/skills/helpers/SKILL.md +206 -0
  48. package/skills/middlewares/SKILL.md +282 -0
  49. package/skills/orm/SKILL.md +260 -0
  50. package/skills/realtime/SKILL.md +307 -0
  51. package/skills/services/SKILL.md +206 -0
  52. 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
+ }
@@ -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
+ }