@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.
- package/dist/event-bus.d.ts +87 -0
- package/dist/event-bus.d.ts.map +1 -0
- package/dist/event-bus.js +265 -0
- package/dist/event-bus.js.map +10 -0
- package/dist/event-registry.d.ts +79 -0
- package/dist/event-registry.d.ts.map +1 -0
- package/dist/event-registry.js +93 -0
- package/dist/event-registry.js.map +10 -0
- package/{src/index.ts → dist/index.d.ts} +14 -28
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +10 -0
- package/dist/router.d.ts +16 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +117 -0
- package/dist/router.js.map +10 -0
- package/dist/service.d.ts +151 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +341 -0
- package/dist/service.js.map +10 -0
- package/dist/types.d.ts +71 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +9 -0
- package/dist/vision-app.d.ts +166 -0
- package/dist/vision-app.d.ts.map +1 -0
- package/dist/vision-app.js +611 -0
- package/dist/vision-app.js.map +10 -0
- package/dist/vision.d.ts +63 -0
- package/dist/vision.d.ts.map +1 -0
- package/dist/vision.js +223 -0
- package/dist/vision.js.map +10 -0
- package/package.json +13 -3
- package/.env.example +0 -3
- package/.eslintrc.cjs +0 -7
- package/.turbo/turbo-build.log +0 -1
- package/src/event-bus.ts +0 -409
- package/src/event-registry.ts +0 -158
- package/src/router.ts +0 -118
- package/src/service.ts +0 -618
- package/src/types.ts +0 -93
- package/src/vision-app.ts +0 -880
- package/src/vision.ts +0 -319
- package/tsconfig.json +0 -9
package/src/vision-app.ts
DELETED
|
@@ -1,880 +0,0 @@
|
|
|
1
|
-
import { Hono } from 'hono'
|
|
2
|
-
import type { Env, Schema } from 'hono'
|
|
3
|
-
import { VisionCore, runInTraceContext } from '@getvision/core'
|
|
4
|
-
import type { RouteMetadata } from '@getvision/core'
|
|
5
|
-
import { AsyncLocalStorage } from 'async_hooks'
|
|
6
|
-
import { existsSync } from 'fs'
|
|
7
|
-
import { spawn, spawnSync, type ChildProcess } from 'child_process'
|
|
8
|
-
import { ServiceBuilder } from './service'
|
|
9
|
-
import { EventBus } from './event-bus'
|
|
10
|
-
import { eventRegistry } from './event-registry'
|
|
11
|
-
import type { serve as honoServe } from '@hono/node-server'
|
|
12
|
-
import type { QueueEventsOptions, QueueOptions, WorkerOptions } from "bullmq";
|
|
13
|
-
|
|
14
|
-
export interface VisionALSContext {
|
|
15
|
-
vision: VisionCore
|
|
16
|
-
traceId: string
|
|
17
|
-
rootSpanId: string
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const visionContext = new AsyncLocalStorage<VisionALSContext>()
|
|
21
|
-
|
|
22
|
-
// Global instance tracking for hot-reload cleanup
|
|
23
|
-
// Must attach to globalThis because module-scoped variables are reset when the module is reloaded
|
|
24
|
-
const GLOBAL_VISION_KEY = '__vision_instance_state'
|
|
25
|
-
interface VisionGlobalState {
|
|
26
|
-
instance: Vision<any, any, any> | null
|
|
27
|
-
drizzleProcess: ChildProcess | null
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Initialize global state if needed
|
|
31
|
-
if (!(globalThis as any)[GLOBAL_VISION_KEY]) {
|
|
32
|
-
(globalThis as any)[GLOBAL_VISION_KEY] = {
|
|
33
|
-
instance: null,
|
|
34
|
-
drizzleProcess: null
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function getGlobalState(): VisionGlobalState {
|
|
39
|
-
return (globalThis as any)[GLOBAL_VISION_KEY]
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function cleanupVisionInstance(instance: Vision<any, any, any>): Promise<void> {
|
|
43
|
-
const existing = (instance as any)._cleanupPromise as Promise<void> | undefined
|
|
44
|
-
if (existing) return existing;
|
|
45
|
-
|
|
46
|
-
(instance as any)._cleanupPromise = (async () => {
|
|
47
|
-
const server = (instance as any).bunServer
|
|
48
|
-
const hasBunServer = server && typeof server.stop === 'function'
|
|
49
|
-
|
|
50
|
-
try {
|
|
51
|
-
if (hasBunServer) {
|
|
52
|
-
server.stop()
|
|
53
|
-
}
|
|
54
|
-
if ((globalThis as any).__vision_bun_server === server) {
|
|
55
|
-
(globalThis as any).__vision_bun_server = undefined
|
|
56
|
-
}
|
|
57
|
-
} catch {}
|
|
58
|
-
|
|
59
|
-
try { stopDrizzleStudio({ log: false }) } catch {}
|
|
60
|
-
try { await (instance as any).eventBus?.close() } catch {}
|
|
61
|
-
})()
|
|
62
|
-
|
|
63
|
-
return (instance as any)._cleanupPromise
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
type BunServeOptions = Parameters<typeof Bun['serve']>[0]
|
|
67
|
-
type NodeServeOptions = Parameters<typeof honoServe>[0]
|
|
68
|
-
|
|
69
|
-
type VisionStartOptions = Omit<Partial<BunServeOptions>, 'fetch' | 'port'> &
|
|
70
|
-
Omit<Partial<NodeServeOptions>, 'fetch' | 'port'>
|
|
71
|
-
|
|
72
|
-
// Simple deep merge utility (objects only, arrays are overwritten by source)
|
|
73
|
-
function deepMerge<T extends Record<string, any>>(target: T, source: Partial<T>): T {
|
|
74
|
-
const output: any = { ...target }
|
|
75
|
-
if (source && typeof source === 'object') {
|
|
76
|
-
for (const key of Object.keys(source)) {
|
|
77
|
-
const srcVal = source[key]
|
|
78
|
-
const tgtVal = output[key]
|
|
79
|
-
if (
|
|
80
|
-
srcVal &&
|
|
81
|
-
typeof srcVal === 'object' &&
|
|
82
|
-
!Array.isArray(srcVal) &&
|
|
83
|
-
tgtVal &&
|
|
84
|
-
typeof tgtVal === 'object' &&
|
|
85
|
-
!Array.isArray(tgtVal)
|
|
86
|
-
) {
|
|
87
|
-
output[key] = deepMerge(tgtVal, srcVal)
|
|
88
|
-
} else {
|
|
89
|
-
output[key] = srcVal
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return output as T
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Vision Server configuration
|
|
98
|
-
*/
|
|
99
|
-
export interface VisionConfig {
|
|
100
|
-
service: {
|
|
101
|
-
name: string
|
|
102
|
-
version?: string
|
|
103
|
-
description?: string
|
|
104
|
-
integrations?: Record<string, string>
|
|
105
|
-
drizzle?: {
|
|
106
|
-
autoStart?: boolean
|
|
107
|
-
port?: number
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
vision?: {
|
|
111
|
-
enabled?: boolean
|
|
112
|
-
port?: number
|
|
113
|
-
maxTraces?: number
|
|
114
|
-
maxLogs?: number
|
|
115
|
-
logging?: boolean
|
|
116
|
-
apiUrl?: string // URL of the API server (for frontend to make HTTP requests)
|
|
117
|
-
}
|
|
118
|
-
routes?: {
|
|
119
|
-
autodiscover?: boolean
|
|
120
|
-
dirs?: string[]
|
|
121
|
-
}
|
|
122
|
-
pubsub?: {
|
|
123
|
-
redis?: {
|
|
124
|
-
host?: string
|
|
125
|
-
port?: number
|
|
126
|
-
password?: string
|
|
127
|
-
/**
|
|
128
|
-
* Enable keepalive to prevent connection timeouts (default: 30000ms)
|
|
129
|
-
*/
|
|
130
|
-
keepAlive?: number
|
|
131
|
-
/**
|
|
132
|
-
* Max retry attempts for failed commands (default: 20)
|
|
133
|
-
*/
|
|
134
|
-
maxRetriesPerRequest?: number
|
|
135
|
-
/**
|
|
136
|
-
* Enable ready check before executing commands (default: true)
|
|
137
|
-
*/
|
|
138
|
-
enableReadyCheck?: boolean
|
|
139
|
-
/**
|
|
140
|
-
* Connection timeout in ms (default: 10000)
|
|
141
|
-
*/
|
|
142
|
-
connectTimeout?: number
|
|
143
|
-
/**
|
|
144
|
-
* Enable offline queue (default: true)
|
|
145
|
-
*/
|
|
146
|
-
enableOfflineQueue?: boolean
|
|
147
|
-
}
|
|
148
|
-
devMode?: boolean // Use in-memory event bus (no Redis required)
|
|
149
|
-
eventBus?: EventBus // Share EventBus instance across apps (for sub-apps)
|
|
150
|
-
/**
|
|
151
|
-
* Default BullMQ worker concurrency for all handlers (overridable per handler)
|
|
152
|
-
*/
|
|
153
|
-
workerConcurrency?: number
|
|
154
|
-
queue?: Omit<QueueOptions, 'connection'>
|
|
155
|
-
worker?: Omit<WorkerOptions, 'connection'>
|
|
156
|
-
queueEvents?: Omit<QueueEventsOptions, 'connection'>
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Vision - Meta-framework built on Hono with observability
|
|
162
|
-
*
|
|
163
|
-
* @example
|
|
164
|
-
* ```ts
|
|
165
|
-
* const app = new Vision({
|
|
166
|
-
* service: {
|
|
167
|
-
* name: 'My API',
|
|
168
|
-
* version: '1.0.0'
|
|
169
|
-
* }
|
|
170
|
-
* })
|
|
171
|
-
*
|
|
172
|
-
* const userService = app.service('users')
|
|
173
|
-
* .on('user/created', handler)
|
|
174
|
-
* .endpoint('GET', '/users/:id', schema, handler)
|
|
175
|
-
*
|
|
176
|
-
* app.start(3000)
|
|
177
|
-
* ```
|
|
178
|
-
*/
|
|
179
|
-
export class Vision<
|
|
180
|
-
E extends Env = Env,
|
|
181
|
-
S extends Schema = {},
|
|
182
|
-
BasePath extends string = '/'
|
|
183
|
-
> extends Hono<E, S, BasePath> {
|
|
184
|
-
private visionCore: VisionCore
|
|
185
|
-
private eventBus: EventBus
|
|
186
|
-
private config: VisionConfig
|
|
187
|
-
private serviceBuilders: ServiceBuilder<any, E>[] = []
|
|
188
|
-
private bunServer?: any
|
|
189
|
-
private signalHandler?: () => Promise<void>
|
|
190
|
-
|
|
191
|
-
constructor(config?: VisionConfig) {
|
|
192
|
-
super()
|
|
193
|
-
|
|
194
|
-
const defaultConfig: VisionConfig = {
|
|
195
|
-
service: {
|
|
196
|
-
name: 'Vision SubApp',
|
|
197
|
-
},
|
|
198
|
-
vision: {
|
|
199
|
-
enabled: false,
|
|
200
|
-
port: 9500,
|
|
201
|
-
},
|
|
202
|
-
// Do not set a default devMode here; let EventBus derive from Redis presence
|
|
203
|
-
pubsub: {},
|
|
204
|
-
routes: {
|
|
205
|
-
autodiscover: true,
|
|
206
|
-
dirs: ['app/routes'],
|
|
207
|
-
},
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Deep merge to respect nested overrides
|
|
211
|
-
this.config = deepMerge(defaultConfig, config || {})
|
|
212
|
-
|
|
213
|
-
// Initialize Vision Core
|
|
214
|
-
const visionEnabled = this.config.vision?.enabled !== false
|
|
215
|
-
const visionPort = this.config.vision?.port ?? 9500
|
|
216
|
-
|
|
217
|
-
if (visionEnabled) {
|
|
218
|
-
this.visionCore = new VisionCore({
|
|
219
|
-
port: visionPort,
|
|
220
|
-
maxTraces: this.config.vision?.maxTraces ?? 1000,
|
|
221
|
-
maxLogs: this.config.vision?.maxLogs ?? 10000,
|
|
222
|
-
apiUrl: this.config.vision?.apiUrl,
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
// Detect and optionally start Drizzle Studio
|
|
226
|
-
const drizzleInfo = detectDrizzle()
|
|
227
|
-
let drizzleStudioUrl: string | undefined
|
|
228
|
-
|
|
229
|
-
if (drizzleInfo.detected) {
|
|
230
|
-
console.log(`🗄️ Drizzle detected (${drizzleInfo.configPath})`)
|
|
231
|
-
|
|
232
|
-
if (this.config.service.drizzle?.autoStart) {
|
|
233
|
-
const drizzlePort = this.config.service.drizzle.port || 4983
|
|
234
|
-
const started = startDrizzleStudio(drizzlePort)
|
|
235
|
-
if (started) {
|
|
236
|
-
drizzleStudioUrl = 'https://local.drizzle.studio'
|
|
237
|
-
}
|
|
238
|
-
} else {
|
|
239
|
-
console.log('💡 Tip: Enable Drizzle Studio auto-start with drizzle: { autoStart: true }')
|
|
240
|
-
drizzleStudioUrl = 'https://local.drizzle.studio'
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// Clean integrations (remove undefined values)
|
|
245
|
-
const cleanIntegrations = Object.fromEntries(
|
|
246
|
-
Object.entries(this.config.service.integrations || {}).filter(([_, v]) => v !== undefined)
|
|
247
|
-
)
|
|
248
|
-
|
|
249
|
-
// Set app status
|
|
250
|
-
this.visionCore.setAppStatus({
|
|
251
|
-
name: this.config.service.name,
|
|
252
|
-
version: this.config.service.version ?? '0.0.0',
|
|
253
|
-
description: this.config.service.description,
|
|
254
|
-
running: true,
|
|
255
|
-
pid: process.pid,
|
|
256
|
-
metadata: {
|
|
257
|
-
framework: 'vision-server',
|
|
258
|
-
integrations: Object.keys(cleanIntegrations).length > 0 ? cleanIntegrations : undefined,
|
|
259
|
-
drizzle: drizzleInfo.detected
|
|
260
|
-
? {
|
|
261
|
-
detected: true,
|
|
262
|
-
configPath: drizzleInfo.configPath,
|
|
263
|
-
studioUrl: drizzleStudioUrl,
|
|
264
|
-
autoStarted: this.config.service.drizzle?.autoStart || false,
|
|
265
|
-
}
|
|
266
|
-
: {
|
|
267
|
-
detected: false,
|
|
268
|
-
configPath: undefined,
|
|
269
|
-
studioUrl: undefined,
|
|
270
|
-
autoStarted: false,
|
|
271
|
-
},
|
|
272
|
-
},
|
|
273
|
-
})
|
|
274
|
-
} else {
|
|
275
|
-
// Create dummy Vision Core that does nothing
|
|
276
|
-
this.visionCore = null as any
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// Use provided EventBus or create a new one
|
|
280
|
-
// Root app creates EventBus, sub-apps can share it via config.pubsub.eventBus
|
|
281
|
-
this.eventBus = this.config.pubsub?.eventBus || new EventBus({
|
|
282
|
-
redis: this.config.pubsub?.redis,
|
|
283
|
-
devMode: this.config.pubsub?.devMode,
|
|
284
|
-
workerConcurrency: this.config.pubsub?.workerConcurrency,
|
|
285
|
-
queue: this.config.pubsub?.queue,
|
|
286
|
-
worker: this.config.pubsub?.worker,
|
|
287
|
-
queueEvents: this.config.pubsub?.queueEvents,
|
|
288
|
-
})
|
|
289
|
-
|
|
290
|
-
// Register JSON-RPC methods for events/cron
|
|
291
|
-
if (visionEnabled) {
|
|
292
|
-
this.registerEventMethods()
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// Install Vision middleware automatically
|
|
296
|
-
if (visionEnabled) {
|
|
297
|
-
this.installVisionMiddleware()
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Register JSON-RPC methods for events and cron jobs
|
|
303
|
-
*/
|
|
304
|
-
private registerEventMethods() {
|
|
305
|
-
const server = this.visionCore.getServer()
|
|
306
|
-
|
|
307
|
-
// List all events
|
|
308
|
-
server.registerMethod('events/list', async () => {
|
|
309
|
-
const events = eventRegistry.getAllEvents()
|
|
310
|
-
return events.map(event => ({
|
|
311
|
-
name: event.name,
|
|
312
|
-
description: event.description,
|
|
313
|
-
icon: event.icon,
|
|
314
|
-
tags: event.tags,
|
|
315
|
-
handlers: event.handlers.length,
|
|
316
|
-
lastTriggered: event.lastTriggered,
|
|
317
|
-
totalCount: event.totalCount,
|
|
318
|
-
failedCount: event.failedCount,
|
|
319
|
-
}))
|
|
320
|
-
})
|
|
321
|
-
|
|
322
|
-
// List all cron jobs
|
|
323
|
-
server.registerMethod('cron/list', async () => {
|
|
324
|
-
const crons = eventRegistry.getAllCrons()
|
|
325
|
-
return crons.map(cron => ({
|
|
326
|
-
name: cron.name,
|
|
327
|
-
schedule: cron.schedule,
|
|
328
|
-
description: cron.description,
|
|
329
|
-
icon: cron.icon,
|
|
330
|
-
tags: cron.tags,
|
|
331
|
-
lastRun: cron.lastRun,
|
|
332
|
-
nextRun: cron.nextRun,
|
|
333
|
-
totalRuns: cron.totalRuns,
|
|
334
|
-
failedRuns: cron.failedRuns,
|
|
335
|
-
}))
|
|
336
|
-
})
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Install Vision tracing middleware
|
|
341
|
-
*/
|
|
342
|
-
private installVisionMiddleware() {
|
|
343
|
-
const logging = this.config.vision?.logging !== false
|
|
344
|
-
|
|
345
|
-
this.use('*', async (c, next) => {
|
|
346
|
-
// Skip OPTIONS requests
|
|
347
|
-
if (c.req.method === 'OPTIONS') {
|
|
348
|
-
return next()
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
const startTime = Date.now()
|
|
352
|
-
|
|
353
|
-
// Create trace
|
|
354
|
-
const trace = this.visionCore.createTrace(c.req.method, c.req.path)
|
|
355
|
-
|
|
356
|
-
// Run request in AsyncLocalStorage context
|
|
357
|
-
return visionContext.run(
|
|
358
|
-
{
|
|
359
|
-
vision: this.visionCore,
|
|
360
|
-
traceId: trace.id,
|
|
361
|
-
rootSpanId: ''
|
|
362
|
-
},
|
|
363
|
-
async () => {
|
|
364
|
-
// Also set core trace context so VisionCore.addContext() works
|
|
365
|
-
return runInTraceContext(trace.id, async () => {
|
|
366
|
-
// Start main span
|
|
367
|
-
const tracer = this.visionCore.getTracer()
|
|
368
|
-
const rootSpan = tracer.startSpan('http.request', trace.id)
|
|
369
|
-
|
|
370
|
-
// Update context with rootSpanId
|
|
371
|
-
const ctx = visionContext.getStore()
|
|
372
|
-
if (ctx) {
|
|
373
|
-
ctx.rootSpanId = rootSpan.id
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// Provide c.span and c.addContext globally for all downstream handlers (Vision/Hono sub-apps)
|
|
377
|
-
if (!(c as any).span) {
|
|
378
|
-
(c as any).addContext = (context: Record<string, unknown>) => {
|
|
379
|
-
const current = visionContext.getStore()
|
|
380
|
-
const currentTraceId = current?.traceId || trace.id
|
|
381
|
-
|
|
382
|
-
// Add context to trace metadata via VisionCore
|
|
383
|
-
const visionTrace = this.visionCore.getTraceStore().getTrace(currentTraceId)
|
|
384
|
-
if (visionTrace) {
|
|
385
|
-
visionTrace.metadata = { ...(visionTrace.metadata || {}), ...context }
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
(c as any).span = <T>(
|
|
390
|
-
name: string,
|
|
391
|
-
attributes: Record<string, any> = {},
|
|
392
|
-
fn?: () => T
|
|
393
|
-
): T => {
|
|
394
|
-
const current = visionContext.getStore()
|
|
395
|
-
const currentTraceId = current?.traceId || trace.id
|
|
396
|
-
const currentRootSpanId = current?.rootSpanId || rootSpan.id
|
|
397
|
-
const s = tracer.startSpan(name, currentTraceId, currentRootSpanId)
|
|
398
|
-
for (const [k, v] of Object.entries(attributes)) tracer.setAttribute(s.id, k, v)
|
|
399
|
-
try {
|
|
400
|
-
const result = fn ? fn() : (undefined as any)
|
|
401
|
-
const completed = tracer.endSpan(s.id)
|
|
402
|
-
if (completed) this.visionCore.getTraceStore().addSpan(currentTraceId, completed)
|
|
403
|
-
return result
|
|
404
|
-
} catch (err) {
|
|
405
|
-
tracer.setAttribute(s.id, 'error', true)
|
|
406
|
-
tracer.setAttribute(s.id, 'error.message', err instanceof Error ? err.message : String(err))
|
|
407
|
-
const completed = tracer.endSpan(s.id)
|
|
408
|
-
if (completed) this.visionCore.getTraceStore().addSpan(currentTraceId, completed)
|
|
409
|
-
throw err
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// Add request attributes
|
|
415
|
-
tracer.setAttribute(rootSpan.id, 'http.method', c.req.method)
|
|
416
|
-
tracer.setAttribute(rootSpan.id, 'http.path', c.req.path)
|
|
417
|
-
tracer.setAttribute(rootSpan.id, 'http.url', c.req.url)
|
|
418
|
-
|
|
419
|
-
// Add query params if any
|
|
420
|
-
const url = new URL(c.req.url)
|
|
421
|
-
if (url.search) {
|
|
422
|
-
tracer.setAttribute(rootSpan.id, 'http.query', url.search)
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// Capture request metadata
|
|
426
|
-
try {
|
|
427
|
-
const rawReq = c.req.raw
|
|
428
|
-
const headers: Record<string, string> = {}
|
|
429
|
-
rawReq.headers.forEach((v, k) => { headers[k] = v })
|
|
430
|
-
|
|
431
|
-
const urlObj = new URL(c.req.url)
|
|
432
|
-
const query: Record<string, string> = {}
|
|
433
|
-
urlObj.searchParams.forEach((v, k) => { query[k] = v })
|
|
434
|
-
|
|
435
|
-
let body: unknown = undefined
|
|
436
|
-
const ct = headers['content-type'] || headers['Content-Type']
|
|
437
|
-
if (ct && ct.includes('application/json')) {
|
|
438
|
-
try {
|
|
439
|
-
body = await rawReq.clone().json()
|
|
440
|
-
} catch {}
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
const sessionId = headers['x-vision-session']
|
|
444
|
-
if (sessionId) {
|
|
445
|
-
tracer.setAttribute(rootSpan.id, 'session.id', sessionId)
|
|
446
|
-
trace.metadata = { ...(trace.metadata || {}), sessionId }
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
const requestMeta = {
|
|
450
|
-
method: c.req.method,
|
|
451
|
-
url: urlObj.pathname + (urlObj.search || ''),
|
|
452
|
-
headers,
|
|
453
|
-
query: Object.keys(query).length ? query : undefined,
|
|
454
|
-
body,
|
|
455
|
-
}
|
|
456
|
-
tracer.setAttribute(rootSpan.id, 'http.request', requestMeta)
|
|
457
|
-
trace.metadata = { ...(trace.metadata || {}), request: requestMeta }
|
|
458
|
-
|
|
459
|
-
// Emit start log
|
|
460
|
-
if (logging) {
|
|
461
|
-
const parts = [
|
|
462
|
-
`method=${c.req.method}`,
|
|
463
|
-
`path=${c.req.path}`,
|
|
464
|
-
]
|
|
465
|
-
if (sessionId) parts.push(`sessionId=${sessionId}`)
|
|
466
|
-
parts.push(`traceId=${trace.id}`)
|
|
467
|
-
console.info(`INF starting request ${parts.join(' ')}`)
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// Execute request
|
|
471
|
-
await next()
|
|
472
|
-
|
|
473
|
-
// Add response attributes
|
|
474
|
-
tracer.setAttribute(rootSpan.id, 'http.status_code', c.res.status)
|
|
475
|
-
const resHeaders: Record<string, string> = {}
|
|
476
|
-
c.res.headers?.forEach((v, k) => { resHeaders[k] = v as unknown as string })
|
|
477
|
-
|
|
478
|
-
let respBody: unknown = undefined
|
|
479
|
-
const resCt = c.res.headers?.get('content-type') || ''
|
|
480
|
-
try {
|
|
481
|
-
const clone = c.res.clone()
|
|
482
|
-
if (resCt.includes('application/json')) {
|
|
483
|
-
const txt = await clone.text()
|
|
484
|
-
if (txt && txt.length <= 65536) {
|
|
485
|
-
try { respBody = JSON.parse(txt) } catch { respBody = txt }
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
} catch {}
|
|
489
|
-
|
|
490
|
-
const responseMeta = {
|
|
491
|
-
status: c.res.status,
|
|
492
|
-
headers: Object.keys(resHeaders).length ? resHeaders : undefined,
|
|
493
|
-
body: respBody,
|
|
494
|
-
}
|
|
495
|
-
tracer.setAttribute(rootSpan.id, 'http.response', responseMeta)
|
|
496
|
-
trace.metadata = { ...(trace.metadata || {}), response: responseMeta }
|
|
497
|
-
|
|
498
|
-
} catch (error) {
|
|
499
|
-
// Track error
|
|
500
|
-
tracer.addEvent(rootSpan.id, 'error', {
|
|
501
|
-
message: error instanceof Error ? error.message : 'Unknown error',
|
|
502
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
503
|
-
})
|
|
504
|
-
|
|
505
|
-
tracer.setAttribute(rootSpan.id, 'error', true)
|
|
506
|
-
throw error
|
|
507
|
-
|
|
508
|
-
} finally {
|
|
509
|
-
// End span and add it to trace
|
|
510
|
-
const completedSpan = tracer.endSpan(rootSpan.id)
|
|
511
|
-
if (completedSpan) {
|
|
512
|
-
this.visionCore.getTraceStore().addSpan(trace.id, completedSpan)
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// Complete trace
|
|
516
|
-
const duration = Date.now() - startTime
|
|
517
|
-
this.visionCore.completeTrace(trace.id, c.res.status, duration)
|
|
518
|
-
|
|
519
|
-
// Add trace ID to response headers
|
|
520
|
-
c.header('X-Vision-Trace-Id', trace.id)
|
|
521
|
-
|
|
522
|
-
// Emit completion log
|
|
523
|
-
if (logging) {
|
|
524
|
-
console.info(
|
|
525
|
-
`INF request completed code=${c.res.status} duration=${duration}ms method=${c.req.method} path=${c.req.path} traceId=${trace.id}`
|
|
526
|
-
)
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
})
|
|
530
|
-
}
|
|
531
|
-
)
|
|
532
|
-
})
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
/**
|
|
537
|
-
* Create a new service with builder pattern
|
|
538
|
-
*
|
|
539
|
-
* @example
|
|
540
|
-
* ```ts
|
|
541
|
-
* const userService = app.service('users')
|
|
542
|
-
* .endpoint('GET', '/users/:id', schema, handler)
|
|
543
|
-
* .on('user/created', handler)
|
|
544
|
-
* ```
|
|
545
|
-
*/
|
|
546
|
-
service<E2 extends Env = E, TEvents extends Record<string, any> = {}>(name: string) {
|
|
547
|
-
const builder = new ServiceBuilder<TEvents, E2>(name, this.eventBus, this.visionCore)
|
|
548
|
-
|
|
549
|
-
// Preserve builder for registration in start()
|
|
550
|
-
this.serviceBuilders.push(builder as unknown as ServiceBuilder<any, E>)
|
|
551
|
-
|
|
552
|
-
return builder
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
/**
|
|
556
|
-
* Get services and routes metadata without registering to this VisionCore
|
|
557
|
-
*/
|
|
558
|
-
public getServiceSummaries(): Array<{ name: string; routes: RouteMetadata[] }> {
|
|
559
|
-
const summaries: Array<{ name: string; routes: RouteMetadata[] }> = []
|
|
560
|
-
for (const builder of this.serviceBuilders) {
|
|
561
|
-
const name = (builder as any).getDisplayName?.() ?? 'Service'
|
|
562
|
-
const rawRoutes = (builder as any).getRoutesMetadata?.()
|
|
563
|
-
if (!rawRoutes || !Array.isArray(rawRoutes)) continue
|
|
564
|
-
const routes: RouteMetadata[] = rawRoutes.map((r: any) => ({
|
|
565
|
-
method: r.method,
|
|
566
|
-
path: r.path,
|
|
567
|
-
handler: name,
|
|
568
|
-
queryParams: r.queryParams,
|
|
569
|
-
requestBody: r.requestBody,
|
|
570
|
-
responseBody: r.responseBody,
|
|
571
|
-
}))
|
|
572
|
-
summaries.push({ name, routes })
|
|
573
|
-
}
|
|
574
|
-
return summaries
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
/**
|
|
578
|
-
* Build all service builders
|
|
579
|
-
*/
|
|
580
|
-
public buildAllServices() {
|
|
581
|
-
const allServices: Array<{ name: string; routes: RouteMetadata[] }> = []
|
|
582
|
-
|
|
583
|
-
// Build all services (this populates allServices via builder.build)
|
|
584
|
-
for (const builder of this.serviceBuilders) {
|
|
585
|
-
builder.build(this as any, allServices)
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// Don't register to VisionCore here - let start() handle it after sub-apps are loaded
|
|
589
|
-
// Just return allServices so they can be registered later
|
|
590
|
-
return allServices
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
/**
|
|
594
|
-
* Get Vision Core instance
|
|
595
|
-
*/
|
|
596
|
-
getVision(): VisionCore {
|
|
597
|
-
return this.visionCore
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
/**
|
|
601
|
-
* Get EventBus instance
|
|
602
|
-
*/
|
|
603
|
-
getEventBus(): EventBus {
|
|
604
|
-
return this.eventBus
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
/**
|
|
609
|
-
* Autoload Vision/Hono sub-apps from configured directories
|
|
610
|
-
*/
|
|
611
|
-
private async autoloadRoutes(): Promise<Array<{ name: string; routes: any[] }>> {
|
|
612
|
-
const enabled = this.config.routes?.autodiscover !== false
|
|
613
|
-
const dirs = this.config.routes?.dirs ?? ['app/routes']
|
|
614
|
-
if (!enabled) return []
|
|
615
|
-
|
|
616
|
-
const existing: string[] = []
|
|
617
|
-
for (const d of dirs) {
|
|
618
|
-
try { if (existsSync(d)) existing.push(d) } catch {}
|
|
619
|
-
}
|
|
620
|
-
if (existing.length === 0) return []
|
|
621
|
-
|
|
622
|
-
const { loadSubApps } = await import('./router')
|
|
623
|
-
let allSubAppSummaries: Array<{ name: string; routes: any[] }> = []
|
|
624
|
-
for (const d of existing) {
|
|
625
|
-
try {
|
|
626
|
-
// Pass EventBus to sub-apps so they share the same instance
|
|
627
|
-
const summaries = await loadSubApps(this as any, d, this.eventBus)
|
|
628
|
-
allSubAppSummaries = allSubAppSummaries.concat(summaries)
|
|
629
|
-
} catch (e) {
|
|
630
|
-
console.error(`❌ Failed to load sub-apps from ${d}:`, (e as any)?.message || e)
|
|
631
|
-
if (e instanceof Error && e.stack) {
|
|
632
|
-
console.error('Stack:', e.stack)
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
return allSubAppSummaries
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
/**
|
|
640
|
-
* Start the server (convenience method)
|
|
641
|
-
*/
|
|
642
|
-
async start(port: number = 3000, options?: VisionStartOptions) {
|
|
643
|
-
const { hostname, ...restOptions } = options || {}
|
|
644
|
-
const { fetch: _bf, port: _bp, ...bunRest } = restOptions as Partial<BunServeOptions>
|
|
645
|
-
const { fetch: _nf, port: _np, ...nodeRest } = restOptions as Partial<NodeServeOptions>
|
|
646
|
-
|
|
647
|
-
// Build all services WITHOUT registering to VisionCore yet
|
|
648
|
-
const rootSummaries = this.buildAllServices()
|
|
649
|
-
// Autoload file-based Vision/Hono sub-apps if enabled (returns merged sub-app summaries)
|
|
650
|
-
const subAppSummaries = await this.autoloadRoutes()
|
|
651
|
-
|
|
652
|
-
// Merge root and sub-app services by name
|
|
653
|
-
const allServices = new Map<string, { name: string; routes: any[] }>()
|
|
654
|
-
|
|
655
|
-
// Add root services first
|
|
656
|
-
for (const summary of rootSummaries || []) {
|
|
657
|
-
allServices.set(summary.name, { name: summary.name, routes: [...summary.routes] })
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
// Merge sub-app services (combine routes if service name already exists)
|
|
661
|
-
for (const summary of subAppSummaries || []) {
|
|
662
|
-
if (allServices.has(summary.name)) {
|
|
663
|
-
const existing = allServices.get(summary.name)!
|
|
664
|
-
existing.routes.push(...summary.routes)
|
|
665
|
-
} else {
|
|
666
|
-
allServices.set(summary.name, { name: summary.name, routes: [...summary.routes] })
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
// Register all services in one call
|
|
671
|
-
if (this.visionCore && allServices.size > 0) {
|
|
672
|
-
const servicesToRegister = Array.from(allServices.values())
|
|
673
|
-
this.visionCore.registerServices(servicesToRegister)
|
|
674
|
-
const flatRoutes = servicesToRegister.flatMap(s => s.routes)
|
|
675
|
-
this.visionCore.registerRoutes(flatRoutes)
|
|
676
|
-
console.log(`✅ Registered ${servicesToRegister.length} total services (${flatRoutes.length} routes)`)
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// Cleanup previous instance before starting new one (hot-reload)
|
|
680
|
-
const state = getGlobalState()
|
|
681
|
-
if (state.instance && state.instance !== this) {
|
|
682
|
-
await cleanupVisionInstance(state.instance)
|
|
683
|
-
}
|
|
684
|
-
state.instance = this
|
|
685
|
-
|
|
686
|
-
console.log(`🚀 Starting ${this.config.service.name}...`)
|
|
687
|
-
console.log(`📡 API Server: http://localhost:${port}`)
|
|
688
|
-
|
|
689
|
-
// Register signal handlers (cleaned up on dispose)
|
|
690
|
-
if (!this.signalHandler) {
|
|
691
|
-
this.signalHandler = async () => {
|
|
692
|
-
const s = getGlobalState()
|
|
693
|
-
if (s.instance) {
|
|
694
|
-
await cleanupVisionInstance(s.instance)
|
|
695
|
-
}
|
|
696
|
-
try { process.exit(0) } catch {}
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
const handleSignal = this.signalHandler
|
|
701
|
-
|
|
702
|
-
process.removeListener('SIGINT', handleSignal)
|
|
703
|
-
process.removeListener('SIGTERM', handleSignal)
|
|
704
|
-
try { process.removeListener('SIGQUIT', handleSignal) } catch {}
|
|
705
|
-
|
|
706
|
-
process.on('SIGINT', handleSignal)
|
|
707
|
-
process.on('SIGTERM', handleSignal)
|
|
708
|
-
try { process.on('SIGQUIT', handleSignal) } catch {}
|
|
709
|
-
|
|
710
|
-
// Bun hot-reload: register dispose callback
|
|
711
|
-
try {
|
|
712
|
-
const hot = (import.meta as any)?.hot
|
|
713
|
-
if (hot && typeof hot.dispose === 'function') {
|
|
714
|
-
hot.dispose(async () => {
|
|
715
|
-
console.log('♻️ Hot reload: reloading...')
|
|
716
|
-
|
|
717
|
-
// 1. Remove signal listeners to prevent accumulation
|
|
718
|
-
process.off('SIGINT', handleSignal)
|
|
719
|
-
process.off('SIGTERM', handleSignal)
|
|
720
|
-
try { process.off('SIGQUIT', handleSignal) } catch {}
|
|
721
|
-
|
|
722
|
-
// 2. Cleanup this instance
|
|
723
|
-
const s = getGlobalState()
|
|
724
|
-
await cleanupVisionInstance(this)
|
|
725
|
-
if (s.instance === this) {
|
|
726
|
-
s.instance = null
|
|
727
|
-
}
|
|
728
|
-
})
|
|
729
|
-
}
|
|
730
|
-
} catch {}
|
|
731
|
-
|
|
732
|
-
// Prefer Bun if available, then Node.js; otherwise instruct the user to serve manually
|
|
733
|
-
if (typeof process !== 'undefined' && process.versions?.bun) {
|
|
734
|
-
const BunServe = (globalThis as any).Bun?.serve
|
|
735
|
-
if (typeof BunServe === 'function') {
|
|
736
|
-
try {
|
|
737
|
-
const existing = (globalThis as any).__vision_bun_server
|
|
738
|
-
if (existing && typeof existing.stop === 'function') {
|
|
739
|
-
try { existing.stop() } catch {}
|
|
740
|
-
}
|
|
741
|
-
} catch {}
|
|
742
|
-
this.bunServer = BunServe({
|
|
743
|
-
...bunRest,
|
|
744
|
-
fetch: this.fetch.bind(this),
|
|
745
|
-
port,
|
|
746
|
-
hostname
|
|
747
|
-
})
|
|
748
|
-
try { (globalThis as any).__vision_bun_server = this.bunServer } catch {}
|
|
749
|
-
} else {
|
|
750
|
-
console.warn('Bun detected but Bun.serve is unavailable')
|
|
751
|
-
return this
|
|
752
|
-
}
|
|
753
|
-
} else if (typeof process !== 'undefined' && process.versions?.node) {
|
|
754
|
-
const { serve } = await import('@hono/node-server')
|
|
755
|
-
serve({
|
|
756
|
-
...nodeRest,
|
|
757
|
-
fetch: this.fetch.bind(this),
|
|
758
|
-
port,
|
|
759
|
-
hostname
|
|
760
|
-
})
|
|
761
|
-
} else {
|
|
762
|
-
// For other runtimes, just return the app
|
|
763
|
-
console.log('⚠️ Use your runtime\'s serve function')
|
|
764
|
-
return this
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
/**
|
|
769
|
-
* Set the EventBus instance (used internally by router to inject shared EventBus)
|
|
770
|
-
*/
|
|
771
|
-
setEventBus(eventBus: EventBus): void {
|
|
772
|
-
this.eventBus = eventBus
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
/**
|
|
777
|
-
* Get Vision context (internal use)
|
|
778
|
-
*/
|
|
779
|
-
export function getVisionContext(): VisionALSContext | undefined {
|
|
780
|
-
return visionContext.getStore()
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
// ============================================================================
|
|
784
|
-
// Drizzle Studio Integration
|
|
785
|
-
// ============================================================================
|
|
786
|
-
|
|
787
|
-
/**
|
|
788
|
-
* Detect Drizzle configuration
|
|
789
|
-
*/
|
|
790
|
-
function detectDrizzle(): { detected: boolean; configPath?: string } {
|
|
791
|
-
const possiblePaths = [
|
|
792
|
-
'drizzle.config.ts',
|
|
793
|
-
'drizzle.config.js',
|
|
794
|
-
'drizzle.config.mjs',
|
|
795
|
-
]
|
|
796
|
-
|
|
797
|
-
for (const path of possiblePaths) {
|
|
798
|
-
if (existsSync(path)) {
|
|
799
|
-
return { detected: true, configPath: path }
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
return { detected: false }
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
/**
|
|
806
|
-
* Start Drizzle Studio
|
|
807
|
-
*/
|
|
808
|
-
function startDrizzleStudio(port: number): boolean {
|
|
809
|
-
const state = getGlobalState()
|
|
810
|
-
if (state.drizzleProcess) {
|
|
811
|
-
console.log('⚠️ Drizzle Studio is already running')
|
|
812
|
-
return false
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
// If Drizzle Studio is already listening on this port, skip spawning but report available
|
|
816
|
-
try {
|
|
817
|
-
if (process.platform === 'win32') {
|
|
818
|
-
const res = spawnSync('powershell', ['-NoProfile', '-Command', `netstat -ano | Select-String -Pattern "LISTENING\\s+.*:${port}\\s"`], { encoding: 'utf-8' })
|
|
819
|
-
if ((res.stdout || '').trim().length > 0) {
|
|
820
|
-
console.log(`⚠️ Drizzle Studio port ${port} already in use; assuming it is running. Skipping auto-start.`)
|
|
821
|
-
return true
|
|
822
|
-
}
|
|
823
|
-
} else {
|
|
824
|
-
const res = spawnSync('lsof', ['-i', `tcp:${port}`, '-sTCP:LISTEN'], { encoding: 'utf-8' })
|
|
825
|
-
if ((res.stdout || '').trim().length > 0) {
|
|
826
|
-
console.log(`⚠️ Drizzle Studio port ${port} already in use; assuming it is running. Skipping auto-start.`)
|
|
827
|
-
return true
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
} catch {}
|
|
831
|
-
|
|
832
|
-
try {
|
|
833
|
-
const proc = spawn('npx', ['drizzle-kit', 'studio', '--port', String(port), '--host', '0.0.0.0'], {
|
|
834
|
-
stdio: 'inherit',
|
|
835
|
-
detached: false,
|
|
836
|
-
shell: process.platform === 'win32',
|
|
837
|
-
})
|
|
838
|
-
|
|
839
|
-
state.drizzleProcess = proc
|
|
840
|
-
|
|
841
|
-
proc.on('error', (error) => {
|
|
842
|
-
console.error('❌ Failed to start Drizzle Studio:', error.message)
|
|
843
|
-
})
|
|
844
|
-
|
|
845
|
-
proc.on('exit', (code) => {
|
|
846
|
-
if (code !== 0 && code !== null) {
|
|
847
|
-
console.error(`❌ Drizzle Studio exited with code ${code}`)
|
|
848
|
-
}
|
|
849
|
-
// Clear global state if it matches this process
|
|
850
|
-
const s = getGlobalState()
|
|
851
|
-
if (s.drizzleProcess === proc) {
|
|
852
|
-
s.drizzleProcess = null
|
|
853
|
-
}
|
|
854
|
-
})
|
|
855
|
-
|
|
856
|
-
console.log(`✅ Drizzle Studio: https://local.drizzle.studio`)
|
|
857
|
-
return true
|
|
858
|
-
} catch (error) {
|
|
859
|
-
console.error('❌ Failed to start Drizzle Studio:', error)
|
|
860
|
-
return false
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
/**
|
|
865
|
-
* Stop Drizzle Studio
|
|
866
|
-
*/
|
|
867
|
-
function stopDrizzleStudio(options?: { log?: boolean }): boolean {
|
|
868
|
-
const state = getGlobalState()
|
|
869
|
-
if (state.drizzleProcess) {
|
|
870
|
-
// Remove all event listeners to prevent memory leaks
|
|
871
|
-
state.drizzleProcess.removeAllListeners()
|
|
872
|
-
state.drizzleProcess.kill()
|
|
873
|
-
state.drizzleProcess = null
|
|
874
|
-
if (options?.log !== false) {
|
|
875
|
-
console.log('🛑 Drizzle Studio stopped')
|
|
876
|
-
}
|
|
877
|
-
return true
|
|
878
|
-
}
|
|
879
|
-
return false
|
|
880
|
-
}
|