@getvision/server 0.0.0-develop-20251031183955
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/.env.example +10 -0
- package/.eslintrc.cjs +7 -0
- package/.turbo/turbo-build.log +1 -0
- package/README.md +542 -0
- package/package.json +42 -0
- package/src/event-bus.ts +286 -0
- package/src/event-registry.ts +158 -0
- package/src/index.ts +64 -0
- package/src/router.ts +100 -0
- package/src/service.ts +412 -0
- package/src/types.ts +74 -0
- package/src/vision-app.ts +685 -0
- package/src/vision.ts +319 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import type { Env, Schema, Input, MiddlewareHandler } from 'hono'
|
|
3
|
+
import { VisionCore } from '@getvision/core'
|
|
4
|
+
import type { RouteMetadata } from '@getvision/core'
|
|
5
|
+
import { AsyncLocalStorage } from 'async_hooks'
|
|
6
|
+
import { existsSync } from 'fs'
|
|
7
|
+
import { spawn, type ChildProcess } from 'child_process'
|
|
8
|
+
import { ServiceBuilder } from './service'
|
|
9
|
+
import type { VisionContext } from './types'
|
|
10
|
+
import { EventBus } from './event-bus'
|
|
11
|
+
import { eventRegistry } from './event-registry'
|
|
12
|
+
|
|
13
|
+
const visionContext = new AsyncLocalStorage<VisionContext>()
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Vision Server configuration
|
|
17
|
+
*/
|
|
18
|
+
export interface VisionConfig {
|
|
19
|
+
service: {
|
|
20
|
+
name: string
|
|
21
|
+
version?: string
|
|
22
|
+
description?: string
|
|
23
|
+
integrations?: Record<string, string>
|
|
24
|
+
drizzle?: {
|
|
25
|
+
autoStart?: boolean
|
|
26
|
+
port?: number
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
vision?: {
|
|
30
|
+
enabled?: boolean
|
|
31
|
+
port?: number
|
|
32
|
+
maxTraces?: number
|
|
33
|
+
maxLogs?: number
|
|
34
|
+
logging?: boolean
|
|
35
|
+
}
|
|
36
|
+
routes?: {
|
|
37
|
+
autodiscover?: boolean
|
|
38
|
+
dirs?: string[]
|
|
39
|
+
}
|
|
40
|
+
pubsub?: {
|
|
41
|
+
redis?: {
|
|
42
|
+
host?: string
|
|
43
|
+
port?: number
|
|
44
|
+
password?: string
|
|
45
|
+
}
|
|
46
|
+
devMode?: boolean // Use in-memory event bus (no Redis required)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Vision - Meta-framework built on Hono with observability
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```ts
|
|
55
|
+
* const app = new Vision({
|
|
56
|
+
* service: {
|
|
57
|
+
* name: 'My API',
|
|
58
|
+
* version: '1.0.0'
|
|
59
|
+
* },
|
|
60
|
+
* pubsub: {
|
|
61
|
+
* schemas: {
|
|
62
|
+
* 'user/created': {
|
|
63
|
+
* data: z.object({ userId: z.string() })
|
|
64
|
+
* }
|
|
65
|
+
* }
|
|
66
|
+
* }
|
|
67
|
+
* })
|
|
68
|
+
*
|
|
69
|
+
* const userService = app.service('users')
|
|
70
|
+
* .endpoint('GET', '/users/:id', schema, handler)
|
|
71
|
+
* .on('user/created', handler)
|
|
72
|
+
*
|
|
73
|
+
* app.start(3000)
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export class Vision<
|
|
77
|
+
E extends Env = Env,
|
|
78
|
+
S extends Schema = {},
|
|
79
|
+
BasePath extends string = '/'
|
|
80
|
+
> extends Hono<E, S, BasePath> {
|
|
81
|
+
private visionCore: VisionCore
|
|
82
|
+
private eventBus: EventBus
|
|
83
|
+
private config: VisionConfig
|
|
84
|
+
private serviceBuilders: ServiceBuilder<any>[] = []
|
|
85
|
+
private fileBasedRoutes: RouteMetadata[] = []
|
|
86
|
+
|
|
87
|
+
constructor(config?: VisionConfig) {
|
|
88
|
+
super()
|
|
89
|
+
|
|
90
|
+
const defaultConfig: VisionConfig = {
|
|
91
|
+
service: {
|
|
92
|
+
name: 'Vision SubApp',
|
|
93
|
+
},
|
|
94
|
+
vision: {
|
|
95
|
+
enabled: false,
|
|
96
|
+
port: 9500,
|
|
97
|
+
},
|
|
98
|
+
pubsub: {
|
|
99
|
+
devMode: true,
|
|
100
|
+
},
|
|
101
|
+
routes: {
|
|
102
|
+
autodiscover: true,
|
|
103
|
+
dirs: ['app/routes'],
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Merge shallowly (good enough for our config structure)
|
|
108
|
+
this.config = {
|
|
109
|
+
...defaultConfig,
|
|
110
|
+
...(config || {}),
|
|
111
|
+
service: { ...defaultConfig.service, ...(config?.service || {}) },
|
|
112
|
+
vision: { ...defaultConfig.vision, ...(config?.vision || {}) },
|
|
113
|
+
pubsub: { ...defaultConfig.pubsub, ...(config?.pubsub || {}) },
|
|
114
|
+
routes: { ...defaultConfig.routes, ...(config?.routes || {}) },
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Initialize Vision Core
|
|
118
|
+
const visionEnabled = this.config.vision?.enabled !== false
|
|
119
|
+
const visionPort = this.config.vision?.port ?? 9500
|
|
120
|
+
|
|
121
|
+
if (visionEnabled) {
|
|
122
|
+
this.visionCore = new VisionCore({
|
|
123
|
+
port: visionPort,
|
|
124
|
+
maxTraces: this.config.vision?.maxTraces ?? 1000,
|
|
125
|
+
maxLogs: this.config.vision?.maxLogs ?? 10000,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// Detect and optionally start Drizzle Studio
|
|
129
|
+
const drizzleInfo = detectDrizzle()
|
|
130
|
+
let drizzleStudioUrl: string | undefined
|
|
131
|
+
|
|
132
|
+
if (drizzleInfo.detected) {
|
|
133
|
+
console.log(`🗄️ Drizzle detected (${drizzleInfo.configPath})`)
|
|
134
|
+
|
|
135
|
+
if (this.config.service.drizzle?.autoStart) {
|
|
136
|
+
const drizzlePort = this.config.service.drizzle.port || 4983
|
|
137
|
+
const started = startDrizzleStudio(drizzlePort)
|
|
138
|
+
if (started) {
|
|
139
|
+
drizzleStudioUrl = 'https://local.drizzle.studio'
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
console.log('💡 Tip: Enable Drizzle Studio auto-start with drizzle: { autoStart: true }')
|
|
143
|
+
drizzleStudioUrl = 'https://local.drizzle.studio'
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Clean integrations (remove undefined values)
|
|
148
|
+
const cleanIntegrations = Object.fromEntries(
|
|
149
|
+
Object.entries(this.config.service.integrations || {}).filter(([_, v]) => v !== undefined)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
// Set app status
|
|
153
|
+
this.visionCore.setAppStatus({
|
|
154
|
+
name: this.config.service.name,
|
|
155
|
+
version: this.config.service.version ?? '0.0.0',
|
|
156
|
+
description: this.config.service.description,
|
|
157
|
+
running: true,
|
|
158
|
+
pid: process.pid,
|
|
159
|
+
metadata: {
|
|
160
|
+
framework: 'vision-server',
|
|
161
|
+
integrations: Object.keys(cleanIntegrations).length > 0 ? cleanIntegrations : undefined,
|
|
162
|
+
drizzle: drizzleInfo.detected
|
|
163
|
+
? {
|
|
164
|
+
detected: true,
|
|
165
|
+
configPath: drizzleInfo.configPath,
|
|
166
|
+
studioUrl: drizzleStudioUrl,
|
|
167
|
+
autoStarted: this.config.service.drizzle?.autoStart || false,
|
|
168
|
+
}
|
|
169
|
+
: {
|
|
170
|
+
detected: false,
|
|
171
|
+
configPath: undefined,
|
|
172
|
+
studioUrl: undefined,
|
|
173
|
+
autoStarted: false,
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
} else {
|
|
178
|
+
// Create dummy Vision Core that does nothing
|
|
179
|
+
this.visionCore = null as any
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Initialize EventBus
|
|
183
|
+
this.eventBus = new EventBus({
|
|
184
|
+
redis: this.config.pubsub?.redis,
|
|
185
|
+
devMode: this.config.pubsub?.devMode,
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// Register JSON-RPC methods for events/cron
|
|
189
|
+
if (visionEnabled) {
|
|
190
|
+
this.registerEventMethods()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Install Vision middleware automatically
|
|
194
|
+
if (visionEnabled) {
|
|
195
|
+
this.installVisionMiddleware()
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Register JSON-RPC methods for events and cron jobs
|
|
201
|
+
*/
|
|
202
|
+
private registerEventMethods() {
|
|
203
|
+
const server = this.visionCore.getServer()
|
|
204
|
+
|
|
205
|
+
// List all events
|
|
206
|
+
server.registerMethod('events/list', async () => {
|
|
207
|
+
const events = eventRegistry.getAllEvents()
|
|
208
|
+
return events.map(event => ({
|
|
209
|
+
name: event.name,
|
|
210
|
+
description: event.description,
|
|
211
|
+
icon: event.icon,
|
|
212
|
+
tags: event.tags,
|
|
213
|
+
handlers: event.handlers.length,
|
|
214
|
+
lastTriggered: event.lastTriggered,
|
|
215
|
+
totalCount: event.totalCount,
|
|
216
|
+
failedCount: event.failedCount,
|
|
217
|
+
}))
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// List all cron jobs
|
|
221
|
+
server.registerMethod('cron/list', async () => {
|
|
222
|
+
const crons = eventRegistry.getAllCrons()
|
|
223
|
+
return crons.map(cron => ({
|
|
224
|
+
name: cron.name,
|
|
225
|
+
schedule: cron.schedule,
|
|
226
|
+
description: cron.description,
|
|
227
|
+
icon: cron.icon,
|
|
228
|
+
tags: cron.tags,
|
|
229
|
+
lastRun: cron.lastRun,
|
|
230
|
+
nextRun: cron.nextRun,
|
|
231
|
+
totalRuns: cron.totalRuns,
|
|
232
|
+
failedRuns: cron.failedRuns,
|
|
233
|
+
}))
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Install Vision tracing middleware
|
|
239
|
+
*/
|
|
240
|
+
private installVisionMiddleware() {
|
|
241
|
+
const logging = this.config.vision?.logging !== false
|
|
242
|
+
|
|
243
|
+
this.use('*', async (c, next) => {
|
|
244
|
+
// Skip OPTIONS requests
|
|
245
|
+
if (c.req.method === 'OPTIONS') {
|
|
246
|
+
return next()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const startTime = Date.now()
|
|
250
|
+
|
|
251
|
+
// Create trace
|
|
252
|
+
const trace = this.visionCore.createTrace(c.req.method, c.req.path)
|
|
253
|
+
|
|
254
|
+
// Run request in AsyncLocalStorage context
|
|
255
|
+
return visionContext.run(
|
|
256
|
+
{
|
|
257
|
+
vision: this.visionCore,
|
|
258
|
+
traceId: trace.id,
|
|
259
|
+
rootSpanId: ''
|
|
260
|
+
},
|
|
261
|
+
async () => {
|
|
262
|
+
// Start main span
|
|
263
|
+
const tracer = this.visionCore.getTracer()
|
|
264
|
+
const rootSpan = tracer.startSpan('http.request', trace.id)
|
|
265
|
+
|
|
266
|
+
// Update context with rootSpanId
|
|
267
|
+
const ctx = visionContext.getStore()
|
|
268
|
+
if (ctx) {
|
|
269
|
+
ctx.rootSpanId = rootSpan.id
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Provide c.span globally for all downstream handlers (Vision/Hono sub-apps)
|
|
273
|
+
if (!(c as any).span) {
|
|
274
|
+
;(c as any).span = <T>(
|
|
275
|
+
name: string,
|
|
276
|
+
attributes: Record<string, any> = {},
|
|
277
|
+
fn?: () => T
|
|
278
|
+
): T => {
|
|
279
|
+
const current = visionContext.getStore()
|
|
280
|
+
const currentTraceId = current?.traceId || trace.id
|
|
281
|
+
const currentRootSpanId = current?.rootSpanId || rootSpan.id
|
|
282
|
+
const s = tracer.startSpan(name, currentTraceId, currentRootSpanId)
|
|
283
|
+
for (const [k, v] of Object.entries(attributes)) tracer.setAttribute(s.id, k, v)
|
|
284
|
+
try {
|
|
285
|
+
const result = fn ? fn() : (undefined as any)
|
|
286
|
+
const completed = tracer.endSpan(s.id)
|
|
287
|
+
if (completed) this.visionCore.getTraceStore().addSpan(currentTraceId, completed)
|
|
288
|
+
return result
|
|
289
|
+
} catch (err) {
|
|
290
|
+
tracer.setAttribute(s.id, 'error', true)
|
|
291
|
+
tracer.setAttribute(s.id, 'error.message', err instanceof Error ? err.message : String(err))
|
|
292
|
+
const completed = tracer.endSpan(s.id)
|
|
293
|
+
if (completed) this.visionCore.getTraceStore().addSpan(currentTraceId, completed)
|
|
294
|
+
throw err
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Add request attributes
|
|
300
|
+
tracer.setAttribute(rootSpan.id, 'http.method', c.req.method)
|
|
301
|
+
tracer.setAttribute(rootSpan.id, 'http.path', c.req.path)
|
|
302
|
+
tracer.setAttribute(rootSpan.id, 'http.url', c.req.url)
|
|
303
|
+
|
|
304
|
+
// Add query params if any
|
|
305
|
+
const url = new URL(c.req.url)
|
|
306
|
+
if (url.search) {
|
|
307
|
+
tracer.setAttribute(rootSpan.id, 'http.query', url.search)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Capture request metadata
|
|
311
|
+
try {
|
|
312
|
+
const rawReq = c.req.raw
|
|
313
|
+
const headers: Record<string, string> = {}
|
|
314
|
+
rawReq.headers.forEach((v, k) => { headers[k] = v })
|
|
315
|
+
|
|
316
|
+
const urlObj = new URL(c.req.url)
|
|
317
|
+
const query: Record<string, string> = {}
|
|
318
|
+
urlObj.searchParams.forEach((v, k) => { query[k] = v })
|
|
319
|
+
|
|
320
|
+
let body: unknown = undefined
|
|
321
|
+
const ct = headers['content-type'] || headers['Content-Type']
|
|
322
|
+
if (ct && ct.includes('application/json')) {
|
|
323
|
+
try {
|
|
324
|
+
body = await rawReq.clone().json()
|
|
325
|
+
} catch {}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const sessionId = headers['x-vision-session']
|
|
329
|
+
if (sessionId) {
|
|
330
|
+
tracer.setAttribute(rootSpan.id, 'session.id', sessionId)
|
|
331
|
+
trace.metadata = { ...(trace.metadata || {}), sessionId }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const requestMeta = {
|
|
335
|
+
method: c.req.method,
|
|
336
|
+
url: urlObj.pathname + (urlObj.search || ''),
|
|
337
|
+
headers,
|
|
338
|
+
query: Object.keys(query).length ? query : undefined,
|
|
339
|
+
body,
|
|
340
|
+
}
|
|
341
|
+
tracer.setAttribute(rootSpan.id, 'http.request', requestMeta)
|
|
342
|
+
trace.metadata = { ...(trace.metadata || {}), request: requestMeta }
|
|
343
|
+
|
|
344
|
+
// Emit start log
|
|
345
|
+
if (logging) {
|
|
346
|
+
const parts = [
|
|
347
|
+
`method=${c.req.method}`,
|
|
348
|
+
`path=${c.req.path}`,
|
|
349
|
+
]
|
|
350
|
+
if (sessionId) parts.push(`sessionId=${sessionId}`)
|
|
351
|
+
parts.push(`traceId=${trace.id}`)
|
|
352
|
+
console.info(`INF starting request ${parts.join(' ')}`)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Execute request
|
|
356
|
+
await next()
|
|
357
|
+
|
|
358
|
+
// Add response attributes
|
|
359
|
+
tracer.setAttribute(rootSpan.id, 'http.status_code', c.res.status)
|
|
360
|
+
const resHeaders: Record<string, string> = {}
|
|
361
|
+
c.res.headers?.forEach((v, k) => { resHeaders[k] = v as unknown as string })
|
|
362
|
+
|
|
363
|
+
let respBody: unknown = undefined
|
|
364
|
+
const resCt = c.res.headers?.get('content-type') || ''
|
|
365
|
+
try {
|
|
366
|
+
const clone = c.res.clone()
|
|
367
|
+
if (resCt.includes('application/json')) {
|
|
368
|
+
const txt = await clone.text()
|
|
369
|
+
if (txt && txt.length <= 65536) {
|
|
370
|
+
try { respBody = JSON.parse(txt) } catch { respBody = txt }
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
} catch {}
|
|
374
|
+
|
|
375
|
+
const responseMeta = {
|
|
376
|
+
status: c.res.status,
|
|
377
|
+
headers: Object.keys(resHeaders).length ? resHeaders : undefined,
|
|
378
|
+
body: respBody,
|
|
379
|
+
}
|
|
380
|
+
tracer.setAttribute(rootSpan.id, 'http.response', responseMeta)
|
|
381
|
+
trace.metadata = { ...(trace.metadata || {}), response: responseMeta }
|
|
382
|
+
|
|
383
|
+
} catch (error) {
|
|
384
|
+
// Track error
|
|
385
|
+
tracer.addEvent(rootSpan.id, 'error', {
|
|
386
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
387
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
tracer.setAttribute(rootSpan.id, 'error', true)
|
|
391
|
+
throw error
|
|
392
|
+
|
|
393
|
+
} finally {
|
|
394
|
+
// End span and add it to trace
|
|
395
|
+
const completedSpan = tracer.endSpan(rootSpan.id)
|
|
396
|
+
if (completedSpan) {
|
|
397
|
+
this.visionCore.getTraceStore().addSpan(trace.id, completedSpan)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Complete trace
|
|
401
|
+
const duration = Date.now() - startTime
|
|
402
|
+
this.visionCore.completeTrace(trace.id, c.res.status, duration)
|
|
403
|
+
|
|
404
|
+
// Add trace ID to response headers
|
|
405
|
+
c.header('X-Vision-Trace-Id', trace.id)
|
|
406
|
+
|
|
407
|
+
// Emit completion log
|
|
408
|
+
if (logging) {
|
|
409
|
+
console.info(
|
|
410
|
+
`INF request completed code=${c.res.status} duration=${duration}ms method=${c.req.method} path=${c.req.path} traceId=${trace.id}`
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
)
|
|
416
|
+
})
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Create a new service with builder pattern
|
|
422
|
+
*
|
|
423
|
+
* @example
|
|
424
|
+
* ```ts
|
|
425
|
+
* const userService = app.service('users')
|
|
426
|
+
* .endpoint('GET', '/users/:id', schema, handler)
|
|
427
|
+
* .on('user/created', handler)
|
|
428
|
+
* ```
|
|
429
|
+
*/
|
|
430
|
+
service<TEvents extends Record<string, any> = {}>(name: string) {
|
|
431
|
+
const builder = new ServiceBuilder<TEvents>(name, this.eventBus, this.visionCore)
|
|
432
|
+
|
|
433
|
+
// Зберігаємо builder для реєстрації в start()
|
|
434
|
+
this.serviceBuilders.push(builder)
|
|
435
|
+
|
|
436
|
+
return builder
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Get services and routes metadata without registering to this VisionCore
|
|
441
|
+
*/
|
|
442
|
+
public getServiceSummaries(): Array<{ name: string; routes: RouteMetadata[] }> {
|
|
443
|
+
const summaries: Array<{ name: string; routes: RouteMetadata[] }> = []
|
|
444
|
+
for (const builder of this.serviceBuilders) {
|
|
445
|
+
const name = (builder as any).getDisplayName?.() ?? 'Service'
|
|
446
|
+
const rawRoutes = (builder as any).getRoutesMetadata?.()
|
|
447
|
+
if (!rawRoutes || !Array.isArray(rawRoutes)) continue
|
|
448
|
+
const routes: RouteMetadata[] = rawRoutes.map((r: any) => ({
|
|
449
|
+
method: r.method,
|
|
450
|
+
path: r.path,
|
|
451
|
+
handler: name,
|
|
452
|
+
requestBody: r.requestBody,
|
|
453
|
+
responseBody: r.responseBody,
|
|
454
|
+
}))
|
|
455
|
+
summaries.push({ name, routes })
|
|
456
|
+
}
|
|
457
|
+
return summaries
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Build all service builders
|
|
462
|
+
*/
|
|
463
|
+
public buildAllServices() {
|
|
464
|
+
const allServices: Array<{ name: string; routes: RouteMetadata[] }> = []
|
|
465
|
+
|
|
466
|
+
// Build all services (this populates allServices via builder.build)
|
|
467
|
+
for (const builder of this.serviceBuilders) {
|
|
468
|
+
builder.build(this as any, allServices)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Group file-based routes by path prefix (e.g., /products, /analytics)
|
|
472
|
+
if (this.fileBasedRoutes.length > 0) {
|
|
473
|
+
const groupedRoutes = new Map<string, RouteMetadata[]>()
|
|
474
|
+
|
|
475
|
+
for (const route of this.fileBasedRoutes) {
|
|
476
|
+
// Extract first path segment as service name
|
|
477
|
+
const segments = route.path.split('/').filter(s => s && !s.startsWith(':'))
|
|
478
|
+
const serviceName = segments[0] || 'root'
|
|
479
|
+
|
|
480
|
+
if (!groupedRoutes.has(serviceName)) {
|
|
481
|
+
groupedRoutes.set(serviceName, [])
|
|
482
|
+
}
|
|
483
|
+
groupedRoutes.get(serviceName)!.push(route)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Add each group as a service
|
|
487
|
+
for (const [name, routes] of groupedRoutes.entries()) {
|
|
488
|
+
const capitalizedName = name.charAt(0).toUpperCase() + name.slice(1)
|
|
489
|
+
allServices.push({
|
|
490
|
+
name: capitalizedName,
|
|
491
|
+
routes
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Don't register to VisionCore here - let start() handle it after sub-apps are loaded
|
|
497
|
+
// Just return allServices so they can be registered later
|
|
498
|
+
return allServices
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Get Vision Core instance
|
|
503
|
+
*/
|
|
504
|
+
getVision(): VisionCore {
|
|
505
|
+
return this.visionCore
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Get EventBus instance
|
|
510
|
+
*/
|
|
511
|
+
getEventBus(): EventBus {
|
|
512
|
+
return this.eventBus
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Autoload Vision/Hono sub-apps from configured directories
|
|
518
|
+
*/
|
|
519
|
+
private async autoloadRoutes(): Promise<Array<{ name: string; routes: any[] }>> {
|
|
520
|
+
const enabled = this.config.routes?.autodiscover !== false
|
|
521
|
+
const dirs = this.config.routes?.dirs ?? ['app/routes']
|
|
522
|
+
if (!enabled) return []
|
|
523
|
+
|
|
524
|
+
const existing: string[] = []
|
|
525
|
+
for (const d of dirs) {
|
|
526
|
+
try { if (existsSync(d)) existing.push(d) } catch {}
|
|
527
|
+
}
|
|
528
|
+
if (existing.length === 0) return []
|
|
529
|
+
|
|
530
|
+
const { loadSubApps } = await import('./router')
|
|
531
|
+
let allSubAppSummaries: Array<{ name: string; routes: any[] }> = []
|
|
532
|
+
for (const d of existing) {
|
|
533
|
+
try {
|
|
534
|
+
const summaries = await loadSubApps(this as any, d)
|
|
535
|
+
allSubAppSummaries = allSubAppSummaries.concat(summaries)
|
|
536
|
+
} catch (e) {
|
|
537
|
+
console.error(`❌ Failed to load sub-apps from ${d}:`, (e as any)?.message || e)
|
|
538
|
+
if (e instanceof Error && e.stack) {
|
|
539
|
+
console.error('Stack:', e.stack)
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return allSubAppSummaries
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Start the server (convenience method)
|
|
548
|
+
*/
|
|
549
|
+
async start(port: number = 3000, options?: { hostname?: string }) {
|
|
550
|
+
// Build all services WITHOUT registering to VisionCore yet
|
|
551
|
+
const rootSummaries = this.buildAllServices()
|
|
552
|
+
// Autoload file-based Vision/Hono sub-apps if enabled (returns merged sub-app summaries)
|
|
553
|
+
const subAppSummaries = await this.autoloadRoutes()
|
|
554
|
+
|
|
555
|
+
// Merge root and sub-app services by name
|
|
556
|
+
const allServices = new Map<string, { name: string; routes: any[] }>()
|
|
557
|
+
|
|
558
|
+
// Add root services first
|
|
559
|
+
for (const summary of rootSummaries || []) {
|
|
560
|
+
allServices.set(summary.name, { name: summary.name, routes: [...summary.routes] })
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Merge sub-app services (combine routes if service name already exists)
|
|
564
|
+
for (const summary of subAppSummaries || []) {
|
|
565
|
+
if (allServices.has(summary.name)) {
|
|
566
|
+
const existing = allServices.get(summary.name)!
|
|
567
|
+
existing.routes.push(...summary.routes)
|
|
568
|
+
} else {
|
|
569
|
+
allServices.set(summary.name, { name: summary.name, routes: [...summary.routes] })
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Register all services in one call
|
|
574
|
+
if (this.visionCore && allServices.size > 0) {
|
|
575
|
+
const servicesToRegister = Array.from(allServices.values())
|
|
576
|
+
this.visionCore.registerServices(servicesToRegister)
|
|
577
|
+
const flatRoutes = servicesToRegister.flatMap(s => s.routes)
|
|
578
|
+
this.visionCore.registerRoutes(flatRoutes)
|
|
579
|
+
console.log(`✅ Registered ${servicesToRegister.length} total services (${flatRoutes.length} routes)`)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
console.log(`🚀 Starting ${this.config.service.name}...`)
|
|
583
|
+
console.log(`📡 API Server: http://localhost:${port}`)
|
|
584
|
+
|
|
585
|
+
// Setup cleanup on exit
|
|
586
|
+
const cleanup = async () => {
|
|
587
|
+
console.log('🛑 Shutting down...')
|
|
588
|
+
await this.eventBus.close()
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
process.on('SIGINT', cleanup)
|
|
592
|
+
process.on('SIGTERM', cleanup)
|
|
593
|
+
|
|
594
|
+
// For Node.js
|
|
595
|
+
if (typeof process !== 'undefined' && process.versions && process.versions.node) {
|
|
596
|
+
const { serve } = await import('@hono/node-server')
|
|
597
|
+
serve({
|
|
598
|
+
fetch: this.fetch.bind(this),
|
|
599
|
+
port,
|
|
600
|
+
hostname: options?.hostname
|
|
601
|
+
})
|
|
602
|
+
} else {
|
|
603
|
+
// For other runtimes, just return the app
|
|
604
|
+
console.log('⚠️ Use your runtime\'s serve function')
|
|
605
|
+
return this
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Get Vision context (internal use)
|
|
612
|
+
*/
|
|
613
|
+
export function getVisionContext(): VisionContext | undefined {
|
|
614
|
+
return visionContext.getStore()
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ============================================================================
|
|
618
|
+
// Drizzle Studio Integration
|
|
619
|
+
// ============================================================================
|
|
620
|
+
|
|
621
|
+
let drizzleStudioProcess: ChildProcess | null = null
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Detect Drizzle configuration
|
|
625
|
+
*/
|
|
626
|
+
function detectDrizzle(): { detected: boolean; configPath?: string } {
|
|
627
|
+
const possiblePaths = [
|
|
628
|
+
'drizzle.config.ts',
|
|
629
|
+
'drizzle.config.js',
|
|
630
|
+
'drizzle.config.mjs',
|
|
631
|
+
]
|
|
632
|
+
|
|
633
|
+
for (const path of possiblePaths) {
|
|
634
|
+
if (existsSync(path)) {
|
|
635
|
+
return { detected: true, configPath: path }
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return { detected: false }
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Start Drizzle Studio
|
|
643
|
+
*/
|
|
644
|
+
function startDrizzleStudio(port: number): boolean {
|
|
645
|
+
if (drizzleStudioProcess) {
|
|
646
|
+
console.log('⚠️ Drizzle Studio is already running')
|
|
647
|
+
return false
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
console.log(`🗄️ Starting Drizzle Studio on port ${port}...`)
|
|
651
|
+
|
|
652
|
+
try {
|
|
653
|
+
drizzleStudioProcess = spawn('npx', ['drizzle-kit', 'studio', '--port', String(port), '--host', '0.0.0.0'], {
|
|
654
|
+
stdio: 'inherit',
|
|
655
|
+
detached: false,
|
|
656
|
+
})
|
|
657
|
+
|
|
658
|
+
drizzleStudioProcess.on('error', (error) => {
|
|
659
|
+
console.error('❌ Failed to start Drizzle Studio:', error.message)
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
drizzleStudioProcess.on('exit', (code) => {
|
|
663
|
+
if (code !== 0 && code !== null) {
|
|
664
|
+
console.error(`❌ Drizzle Studio exited with code ${code}`)
|
|
665
|
+
}
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
console.log(`✅ Drizzle Studio: https://local.drizzle.studio`)
|
|
669
|
+
return true
|
|
670
|
+
} catch (error) {
|
|
671
|
+
console.error('❌ Failed to start Drizzle Studio:', error)
|
|
672
|
+
return false
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Stop Drizzle Studio
|
|
678
|
+
*/
|
|
679
|
+
function stopDrizzleStudio(): void {
|
|
680
|
+
if (drizzleStudioProcess) {
|
|
681
|
+
drizzleStudioProcess.kill()
|
|
682
|
+
drizzleStudioProcess = null
|
|
683
|
+
console.log('🛑 Drizzle Studio stopped')
|
|
684
|
+
}
|
|
685
|
+
}
|