@getvision/server 0.4.3 → 0.4.4-44d79d9-develop

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 (44) hide show
  1. package/dist/event-bus.d.ts +87 -0
  2. package/dist/event-bus.d.ts.map +1 -0
  3. package/dist/event-bus.js +265 -0
  4. package/dist/event-bus.js.map +10 -0
  5. package/dist/event-registry.d.ts +79 -0
  6. package/dist/event-registry.d.ts.map +1 -0
  7. package/dist/event-registry.js +93 -0
  8. package/dist/event-registry.js.map +10 -0
  9. package/{src/index.ts → dist/index.d.ts} +14 -28
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +33 -0
  12. package/dist/index.js.map +10 -0
  13. package/dist/router.d.ts +16 -0
  14. package/dist/router.d.ts.map +1 -0
  15. package/dist/router.js +117 -0
  16. package/dist/router.js.map +10 -0
  17. package/dist/service.d.ts +151 -0
  18. package/dist/service.d.ts.map +1 -0
  19. package/dist/service.js +341 -0
  20. package/dist/service.js.map +10 -0
  21. package/dist/types.d.ts +71 -0
  22. package/dist/types.d.ts.map +1 -0
  23. package/dist/types.js +2 -0
  24. package/dist/types.js.map +9 -0
  25. package/dist/vision-app.d.ts +166 -0
  26. package/dist/vision-app.d.ts.map +1 -0
  27. package/dist/vision-app.js +611 -0
  28. package/dist/vision-app.js.map +10 -0
  29. package/dist/vision.d.ts +63 -0
  30. package/dist/vision.d.ts.map +1 -0
  31. package/dist/vision.js +223 -0
  32. package/dist/vision.js.map +10 -0
  33. package/package.json +13 -3
  34. package/.env.example +0 -3
  35. package/.eslintrc.cjs +0 -7
  36. package/.turbo/turbo-build.log +0 -1
  37. package/src/event-bus.ts +0 -409
  38. package/src/event-registry.ts +0 -158
  39. package/src/router.ts +0 -118
  40. package/src/service.ts +0 -618
  41. package/src/types.ts +0 -93
  42. package/src/vision-app.ts +0 -880
  43. package/src/vision.ts +0 -319
  44. package/tsconfig.json +0 -9
package/src/event-bus.ts DELETED
@@ -1,409 +0,0 @@
1
- import { Queue, Worker, QueueEvents } from 'bullmq'
2
- import type { QueueEventsOptions, QueueOptions, WorkerOptions } from 'bullmq'
3
- import { eventRegistry } from './event-registry'
4
-
5
- /**
6
- * EventBus configuration
7
- */
8
- export interface EventBusConfig {
9
- redis?: {
10
- host?: string
11
- port?: number
12
- password?: string
13
- /**
14
- * Enable keepalive to prevent connection timeouts (default: true in production)
15
- */
16
- keepAlive?: number
17
- /**
18
- * Max retry attempts for failed commands (default: 20)
19
- */
20
- maxRetriesPerRequest?: number
21
- /**
22
- * Enable ready check before executing commands (default: true)
23
- */
24
- enableReadyCheck?: boolean
25
- /**
26
- * Connection timeout in ms (default: 10000)
27
- */
28
- connectTimeout?: number
29
- /**
30
- * Enable offline queue (default: true)
31
- */
32
- enableOfflineQueue?: boolean
33
- }
34
- queue?: Omit<QueueOptions, 'connection'>
35
- worker?: Omit<WorkerOptions, 'connection'>
36
- queueEvents?: Omit<QueueEventsOptions, 'connection'>
37
- /**
38
- * Default BullMQ worker concurrency (per event). Per-handler options override this.
39
- */
40
- workerConcurrency?: number
41
- // Dev mode - use in-memory (no Redis required)
42
- devMode?: boolean
43
- }
44
-
45
- /**
46
- * EventBus - Abstraction over BullMQ
47
- */
48
- export class EventBus {
49
- private queues = new Map<string, Queue>()
50
- private workers = new Map<string, Worker>()
51
- private queueEvents = new Map<string, QueueEvents>()
52
- private config: EventBusConfig
53
- private devModeHandlers = new Map<string, Array<(data: any) => Promise<void>>>()
54
-
55
- constructor(config: EventBusConfig = {}) {
56
- // Build Redis config from environment variables
57
- const envUrl = process.env.REDIS_URL
58
- let envRedis: { host?: string; port?: number; password?: string } | undefined
59
- if (envUrl) {
60
- try {
61
- const u = new URL(envUrl)
62
- envRedis = {
63
- host: u.hostname || undefined,
64
- port: u.port ? parseInt(u.port) : 6379,
65
- // URL password takes precedence over REDIS_PASSWORD
66
- password: u.password || process.env.REDIS_PASSWORD || undefined,
67
- }
68
- } catch {
69
- // Fallback to individual env vars if URL is invalid
70
- envRedis = undefined
71
- }
72
- }
73
-
74
- if (!envRedis) {
75
- envRedis = {
76
- host: process.env.REDIS_HOST || 'localhost',
77
- port: parseInt(process.env.REDIS_PORT || '6379'),
78
- password: process.env.REDIS_PASSWORD || undefined,
79
- }
80
- }
81
-
82
- // Merge: explicit config.redis overrides env-derived values
83
- const mergedRedis = { ...(envRedis || {}), ...(config.redis || {}) }
84
-
85
- // Determine if Redis is configured by env or explicit config
86
- const hasRedisFromEnv = Boolean(envUrl)
87
- const hasRedisFromConfig = Boolean(
88
- config.redis && (config.redis.host || config.redis.port || config.redis.password)
89
- )
90
- const hasRedis = hasRedisFromEnv || hasRedisFromConfig
91
-
92
- // devMode precedence:
93
- // 1) Respect explicit config.devMode when provided (true/false)
94
- // 2) Otherwise, if Redis is configured (env or config), use production mode (devMode=false)
95
- // 3) Otherwise, default to devMode=true (in-memory)
96
- const resolvedDevMode =
97
- typeof config.devMode === 'boolean' ? config.devMode : !hasRedis
98
-
99
- this.config = {
100
- devMode: resolvedDevMode,
101
- redis: mergedRedis,
102
- workerConcurrency: config.workerConcurrency,
103
- queue: config.queue,
104
- worker: config.worker,
105
- queueEvents: config.queueEvents,
106
- }
107
- }
108
-
109
- /**
110
- * Get Redis connection configuration
111
- * Includes keepalive, retry strategy, and connection pooling
112
- */
113
- private getRedisConnection() {
114
- const baseConfig = this.config.redis || {
115
- host: 'localhost',
116
- port: 6379,
117
- }
118
-
119
- return {
120
- host: baseConfig.host,
121
- port: baseConfig.port,
122
- password: baseConfig.password,
123
- keepAlive: baseConfig.keepAlive ?? 30000, // 30 seconds keepalive
124
- maxRetriesPerRequest: baseConfig.maxRetriesPerRequest ?? 20,
125
- enableReadyCheck: baseConfig.enableReadyCheck ?? true,
126
- connectTimeout: baseConfig.connectTimeout ?? 10000,
127
- enableOfflineQueue: baseConfig.enableOfflineQueue ?? true,
128
- // Retry strategy for automatic reconnection
129
- retryStrategy: (times: number) => {
130
- if (times > 10) {
131
- console.error('❌ Redis connection failed after 10 retries')
132
- return null // Stop retrying
133
- }
134
- const delay = Math.min(times * 200, 3000)
135
- console.log(`🔄 Redis reconnecting... attempt ${times}, delay ${delay}ms`)
136
- return delay
137
- },
138
- }
139
- }
140
-
141
- /**
142
- * Get or create a queue for an event
143
- */
144
- private getQueue(eventName: string): Queue {
145
- if (this.config.devMode) {
146
- // In dev mode, we don't use actual queues
147
- return null as any
148
- }
149
-
150
- let queue = this.queues.get(eventName)
151
- if (!queue) {
152
- queue = new Queue(eventName, {
153
- ...(this.config.queue || {}),
154
- connection: this.getRedisConnection(),
155
- })
156
- this.queues.set(eventName, queue)
157
- }
158
- return queue
159
- }
160
-
161
- /**
162
- * Get or create a queue for a cron job
163
- */
164
- async getQueueForCron(cronName: string): Promise<Queue> {
165
- if (this.config.devMode) {
166
- // In dev mode, return a mock queue
167
- return {
168
- upsertJobScheduler: async () => {},
169
- } as any
170
- }
171
-
172
- return this.getQueue(cronName)
173
- }
174
-
175
- /**
176
- * Emit an event
177
- */
178
- async emit<T extends Record<string, any>>(
179
- eventName: string,
180
- data: T
181
- ): Promise<void> {
182
- // Get event metadata from registry
183
- const eventMeta = eventRegistry.getEvent(eventName)
184
- if (!eventMeta) {
185
- throw new Error(`Event "${eventName}" not registered. Did you forget to add .on('${eventName}', {...})?`)
186
- }
187
-
188
- // Validate data with Zod schema (enforce no unknown keys when possible)
189
- try {
190
- // If the event schema is a ZodObject, use strict() to disallow unknown keys
191
- const maybeStrictSchema: any = (eventMeta.schema as any)
192
- const strictSchema = typeof maybeStrictSchema?.strict === 'function'
193
- ? maybeStrictSchema.strict()
194
- : eventMeta.schema
195
-
196
- const validatedData = (strictSchema as typeof eventMeta.schema).parse(data)
197
-
198
- if (this.config.devMode) {
199
- // Dev mode - execute handlers immediately (in-memory)
200
- const handlers = this.devModeHandlers.get(eventName) || []
201
-
202
- for (const handler of handlers) {
203
- try {
204
- await handler(validatedData)
205
- eventRegistry.incrementEventCount(eventName, false)
206
- } catch (error) {
207
- console.error(`Error in handler for event "${eventName}":`, error)
208
- eventRegistry.incrementEventCount(eventName, true)
209
- throw error
210
- }
211
- }
212
- } else {
213
- // Production mode - use BullMQ
214
- const queue = this.getQueue(eventName)
215
- await queue.add(eventName, validatedData, {
216
- attempts: 3,
217
- backoff: {
218
- type: 'exponential',
219
- delay: 2000,
220
- },
221
- })
222
- eventRegistry.incrementEventCount(eventName, false)
223
- }
224
- } catch (error) {
225
- if (error instanceof Error && error.name === 'ZodError') {
226
- const zodError = error as any
227
- const errorMessages = zodError.errors?.map((e: any) => ` - ${e.path.join('.')}: ${e.message}`).join('\n') || error.message
228
- throw new Error(
229
- `Invalid data for event "${eventName}":\n${errorMessages}`
230
- )
231
- }
232
- throw error
233
- }
234
- }
235
-
236
- /**
237
- * Register event handler
238
- */
239
- registerHandler<T>(
240
- eventName: string,
241
- handler: (data: T) => Promise<void>,
242
- options?: {
243
- /**
244
- * Max number of concurrent jobs this handler will process.
245
- * Defaults to config.workerConcurrency (or 1).
246
- */
247
- concurrency?: number
248
- }
249
- ): void {
250
- if (this.config.devMode) {
251
- // Dev mode - store handlers in memory
252
- const handlers = this.devModeHandlers.get(eventName) || []
253
- handlers.push(handler)
254
- this.devModeHandlers.set(eventName, handlers)
255
- } else {
256
- // Production mode - create BullMQ worker
257
- const workerKey = `${eventName}-handler`
258
-
259
- // Close existing worker if it exists
260
- const existingWorker = this.workers.get(workerKey)
261
- if (existingWorker) {
262
- void existingWorker.close().catch(() => {})
263
- this.workers.delete(workerKey)
264
- }
265
-
266
- const worker = new Worker(
267
- eventName,
268
- async (job) => {
269
- try {
270
- await handler(job.data)
271
- } catch (error) {
272
- eventRegistry.incrementEventCount(eventName, true)
273
- throw error
274
- }
275
- },
276
- {
277
- ...(this.config.worker || {}),
278
- connection: this.getRedisConnection(),
279
- concurrency:
280
- options?.concurrency ??
281
- this.config.workerConcurrency ??
282
- this.config.worker?.concurrency ??
283
- 1,
284
- }
285
- )
286
-
287
- this.workers.set(workerKey, worker)
288
-
289
- // Listen to queue events
290
- if (!this.queueEvents.has(eventName)) {
291
- const queueEvents = new QueueEvents(eventName, {
292
- ...(this.config.queueEvents || {}),
293
- connection: this.getRedisConnection(),
294
- })
295
-
296
- queueEvents.on('completed', ({ jobId }) => {
297
- console.log(`✅ Event "${eventName}" completed (job: ${jobId})`)
298
- })
299
-
300
- queueEvents.on('failed', ({ jobId, failedReason }) => {
301
- console.error(`❌ Event "${eventName}" failed (job: ${jobId}):`, failedReason)
302
- })
303
-
304
- this.queueEvents.set(eventName, queueEvents)
305
- }
306
- }
307
- }
308
-
309
- /**
310
- * Register cron job handler
311
- */
312
- registerCronHandler(
313
- cronName: string,
314
- handler: (context: any) => Promise<void>
315
- ): void {
316
- if (this.config.devMode) {
317
- // Dev mode - cron jobs run immediately for testing
318
- // We'll implement a simple interval-based scheduler
319
- console.log(`🧹 Cron job "${cronName}" registered (dev mode - manual trigger only)`)
320
-
321
- // Store handler for manual trigger
322
- const handlers = this.devModeHandlers.get(cronName) || []
323
- handlers.push(handler)
324
- this.devModeHandlers.set(cronName, handlers)
325
- } else {
326
- // Production mode - create BullMQ worker for cron jobs
327
- const cronWorkerKey = `${cronName}-handler`
328
-
329
- // Close existing cron worker if it exists
330
- const existingCronWorker = this.workers.get(cronWorkerKey)
331
- if (existingCronWorker) {
332
- void existingCronWorker.close().catch(() => {})
333
- this.workers.delete(cronWorkerKey)
334
- }
335
-
336
- const worker = new Worker(
337
- cronName,
338
- async (job) => {
339
- try {
340
- // Create a simple context for cron handler
341
- const context = {
342
- jobId: job.id,
343
- timestamp: Date.now(),
344
- }
345
- await handler(context)
346
- eventRegistry.incrementCronCount(cronName, false)
347
- } catch (error) {
348
- eventRegistry.incrementCronCount(cronName, true)
349
- throw error
350
- }
351
- },
352
- {
353
- ...(this.config.worker || {}),
354
- connection: this.getRedisConnection(),
355
- concurrency: this.config.worker?.concurrency ?? 1,
356
- }
357
- )
358
-
359
- this.workers.set(cronWorkerKey, worker)
360
-
361
- // Listen to cron job events
362
- if (!this.queueEvents.has(cronName)) {
363
- const queueEvents = new QueueEvents(cronName, {
364
- ...(this.config.queueEvents || {}),
365
- connection: this.getRedisConnection(),
366
- })
367
-
368
- queueEvents.on('completed', ({ jobId }) => {
369
- console.log(`✅ Cron job "${cronName}" completed (job: ${jobId})`)
370
- })
371
-
372
- queueEvents.on('failed', ({ jobId, failedReason }) => {
373
- console.error(`❌ Cron job "${cronName}" failed (job: ${jobId}):`, failedReason)
374
- })
375
-
376
- this.queueEvents.set(cronName, queueEvents)
377
- }
378
- }
379
- }
380
-
381
- /**
382
- * Close all connections
383
- */
384
- async close(): Promise<void> {
385
- if (this.config.devMode) {
386
- this.devModeHandlers.clear()
387
- return
388
- }
389
-
390
- // Close all queues
391
- for (const queue of this.queues.values()) {
392
- await queue.close()
393
- }
394
-
395
- // Close all workers
396
- for (const worker of this.workers.values()) {
397
- await worker.close()
398
- }
399
-
400
- // Close all queue events
401
- for (const queueEvent of this.queueEvents.values()) {
402
- await queueEvent.close()
403
- }
404
-
405
- this.queues.clear()
406
- this.workers.clear()
407
- this.queueEvents.clear()
408
- }
409
- }
@@ -1,158 +0,0 @@
1
- import type { z } from 'zod'
2
-
3
- /**
4
- * Event metadata for UI and runtime
5
- */
6
- export interface EventMetadata<T = any> {
7
- name: string
8
- description?: string
9
- icon?: string
10
- tags?: string[]
11
- schema: z.ZodSchema<T>
12
- handlers: Array<(data: T) => Promise<void>>
13
- lastTriggered?: Date
14
- totalCount: number
15
- failedCount: number
16
- }
17
-
18
- /**
19
- * Cron job metadata
20
- */
21
- export interface CronMetadata {
22
- name: string
23
- schedule: string
24
- description?: string
25
- icon?: string
26
- tags?: string[]
27
- handler: (context: any) => Promise<void>
28
- lastRun?: Date
29
- nextRun?: Date
30
- totalRuns: number
31
- failedRuns: number
32
- }
33
-
34
- /**
35
- * Global event registry
36
- */
37
- export class EventRegistry {
38
- private events = new Map<string, EventMetadata>()
39
- private crons = new Map<string, CronMetadata>()
40
-
41
- /**
42
- * Register an event with schema and handler
43
- */
44
- registerEvent<T>(
45
- name: string,
46
- schema: z.ZodSchema<T>,
47
- handler: (data: T) => Promise<void>,
48
- metadata?: {
49
- description?: string
50
- icon?: string
51
- tags?: string[]
52
- }
53
- ): void {
54
- const existing = this.events.get(name)
55
-
56
- if (existing) {
57
- // Add handler to existing event
58
- existing.handlers.push(handler)
59
- } else {
60
- // Create new event
61
- this.events.set(name, {
62
- name,
63
- schema,
64
- handlers: [handler],
65
- description: metadata?.description,
66
- icon: metadata?.icon,
67
- tags: metadata?.tags,
68
- totalCount: 0,
69
- failedCount: 0,
70
- })
71
- }
72
- }
73
-
74
- /**
75
- * Register a cron job
76
- */
77
- registerCron(
78
- name: string,
79
- schedule: string,
80
- handler: (context: any) => Promise<void>,
81
- metadata?: {
82
- description?: string
83
- icon?: string
84
- tags?: string[]
85
- }
86
- ): void {
87
- this.crons.set(name, {
88
- name,
89
- schedule,
90
- handler,
91
- description: metadata?.description,
92
- icon: metadata?.icon,
93
- tags: metadata?.tags,
94
- totalRuns: 0,
95
- failedRuns: 0,
96
- })
97
- }
98
-
99
- /**
100
- * Get event metadata
101
- */
102
- getEvent(name: string): EventMetadata | undefined {
103
- return this.events.get(name)
104
- }
105
-
106
- /**
107
- * Get all events
108
- */
109
- getAllEvents(): EventMetadata[] {
110
- return Array.from(this.events.values())
111
- }
112
-
113
- /**
114
- * Get all cron jobs
115
- */
116
- getAllCrons(): CronMetadata[] {
117
- return Array.from(this.crons.values())
118
- }
119
-
120
- /**
121
- * Increment event count
122
- */
123
- incrementEventCount(name: string, failed = false): void {
124
- const event = this.events.get(name)
125
- if (event) {
126
- event.totalCount++
127
- if (failed) {
128
- event.failedCount++
129
- }
130
- event.lastTriggered = new Date()
131
- }
132
- }
133
-
134
- /**
135
- * Increment cron run count
136
- */
137
- incrementCronCount(name: string, failed = false): void {
138
- const cron = this.crons.get(name)
139
- if (cron) {
140
- cron.totalRuns++
141
- if (failed) {
142
- cron.failedRuns++
143
- }
144
- cron.lastRun = new Date()
145
- }
146
- }
147
-
148
- /**
149
- * Clear all registrations (for testing)
150
- */
151
- clear(): void {
152
- this.events.clear()
153
- this.crons.clear()
154
- }
155
- }
156
-
157
- // Global singleton
158
- export const eventRegistry = new EventRegistry()
package/src/router.ts DELETED
@@ -1,118 +0,0 @@
1
- import type { Hono } from 'hono'
2
- import { readdirSync, statSync } from 'fs'
3
- import { join, resolve, relative, sep } from 'path'
4
- import { pathToFileURL } from 'url'
5
- import type { EventBus } from './event-bus'
6
-
7
- /**
8
- * Autoload Vision/Hono sub-apps from a directory structure like app/routes/.../index.ts
9
- * Each folder becomes a base path. Dynamic segments [id] are converted to :id.
10
- *
11
- * Examples:
12
- * - app/routes/users/index.ts -> /users
13
- * - app/routes/users/[id]/index.ts -> /users/:id
14
- * - app/routes/index.ts -> /
15
- */
16
- export async function loadSubApps(app: Hono, routesDir: string = './app/routes', eventBus?: EventBus): Promise<Array<{ name: string; routes: any[] }>> {
17
- const mounted: Array<{ base: string }> = []
18
- const allSubAppSummaries: Array<{ name: string; routes: any[] }> = []
19
-
20
- function toBasePath(dirPath: string): string {
21
- const rel = relative(resolve(routesDir), resolve(dirPath))
22
- if (!rel || rel === '' || rel === '.' ) return '/'
23
- const segments = rel.split(sep).filter(Boolean).map((s) => {
24
- if (s.startsWith('[') && s.endsWith(']')) return `:${s.slice(1, -1)}`
25
- return s
26
- })
27
- return '/' + segments.join('/')
28
- }
29
-
30
- function isDynamicSegment(name: string): boolean {
31
- return name.startsWith('[') && name.endsWith(']')
32
- }
33
-
34
- async function scan(dir: string) {
35
- const entries = readdirSync(dir)
36
- // If folder contains index.ts or index.js, treat it as a sub-app root
37
- const hasTs = entries.includes('index.ts')
38
- const hasJs = entries.includes('index.js')
39
- if (hasTs || hasJs) {
40
- const indexFile = resolve(dir, hasTs ? 'index.ts' : 'index.js')
41
- const modUrl = pathToFileURL(indexFile).href
42
- const mod: any = await import(modUrl)
43
- const subApp = mod?.default
44
- if (subApp) {
45
- // Inject EventBus into sub-app if it's a Vision instance
46
- if (eventBus && typeof subApp?.setEventBus === 'function') {
47
- subApp.setEventBus(eventBus)
48
- }
49
- const base = toBasePath(dir)
50
- // If it's a Vision sub-app, build its services before mounting
51
- try {
52
- if (typeof (subApp as any)?.service === 'function') {
53
- await (subApp as any).buildAllServices?.()
54
- // Collect sub-app services/routes for bulk registration later
55
- const summaries = (subApp as any).getServiceSummaries?.()
56
- if (Array.isArray(summaries) && summaries.length > 0) {
57
- // Prefix all route paths with the base path
58
- const prefixedSummaries = summaries.map(s => ({
59
- ...s,
60
- routes: s.routes.map((r: any) => ({
61
- ...r,
62
- path: base === '/' ? r.path : base + (r.path === '/' ? '' : r.path)
63
- }))
64
- }))
65
- allSubAppSummaries.push(...prefixedSummaries)
66
- }
67
- }
68
- } catch (e) {
69
- console.error(`❌ Error preparing sub-app ${dir}:`, (e as any)?.message || e)
70
- }
71
- // Mount the sub-app only if it looks like a Hono/Vision instance with routes
72
- const routes = (subApp as any)?.routes
73
- if (Array.isArray(routes)) {
74
- ;(app as any).route(base, subApp)
75
- mounted.push({ base })
76
- }
77
- }
78
- }
79
- // Recurse into child directories
80
- // Sort entries: static folders first, then dynamic [param] folders
81
- // This ensures static routes have priority over dynamic routes
82
- const sortedEntries = [...entries].sort((a, b) => {
83
- const aIsDynamic = isDynamicSegment(a)
84
- const bIsDynamic = isDynamicSegment(b)
85
- if (aIsDynamic && !bIsDynamic) return 1 // dynamic after static
86
- if (!aIsDynamic && bIsDynamic) return -1 // static before dynamic
87
- return a.localeCompare(b) // alphabetical within same type
88
- })
89
- for (const name of sortedEntries) {
90
- const full = join(dir, name)
91
- const st = statSync(full)
92
- if (st.isDirectory()) await scan(full)
93
- }
94
- }
95
-
96
- // Only scan if directory exists
97
- try {
98
- statSync(routesDir)
99
- } catch {
100
- return []
101
- }
102
-
103
- await scan(routesDir)
104
-
105
- // Merge services by name (combine routes from same service name)
106
- const mergedServices = new Map<string, { name: string; routes: any[] }>()
107
- for (const summary of allSubAppSummaries) {
108
- if (mergedServices.has(summary.name)) {
109
- const existing = mergedServices.get(summary.name)!
110
- existing.routes.push(...summary.routes)
111
- } else {
112
- mergedServices.set(summary.name, { name: summary.name, routes: [...summary.routes] })
113
- }
114
- }
115
-
116
- // Return merged services (don't register here - let caller handle it)
117
- return Array.from(mergedServices.values())
118
- }