@getvision/adapter-express 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.
@@ -0,0 +1,98 @@
1
+ // Store schemas for Vision introspection
2
+ const routeSchemas = new Map();
3
+ /**
4
+ * Get stored schema for a route
5
+ */
6
+ export function getRouteSchema(method, path) {
7
+ const key = `${method}:${path}`;
8
+ return routeSchemas.get(key)?.schema;
9
+ }
10
+ /**
11
+ * Get all stored schemas
12
+ */
13
+ export function getAllRouteSchemas() {
14
+ return routeSchemas;
15
+ }
16
+ /**
17
+ * Zod validator middleware for Express
18
+ * Similar to @hono/zod-validator but stores schema for Vision introspection
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * import { zValidator } from '@getvision/adapter-express'
23
+ * import { z } from 'zod'
24
+ *
25
+ * const schema = z.object({
26
+ * name: z.string().describe('User name'),
27
+ * email: z.string().email().describe('User email'),
28
+ * })
29
+ *
30
+ * app.post('/users', zValidator('body', schema), (req, res) => {
31
+ * // req.body is now typed and validated
32
+ * const { name, email } = req.body
33
+ * res.json({ name, email })
34
+ * })
35
+ * ```
36
+ */
37
+ export function zValidator(target, schema) {
38
+ const middleware = (req, res, next) => {
39
+ // Store schema for Vision (we'll update it later with actual route info)
40
+ const key = `${req.method}:${req.route?.path || req.path}`;
41
+ if (req.route?.path) {
42
+ routeSchemas.set(key, {
43
+ method: req.method,
44
+ path: req.route.path,
45
+ schema,
46
+ });
47
+ }
48
+ // Get data to validate based on target
49
+ let data;
50
+ switch (target) {
51
+ case 'body':
52
+ data = req.body;
53
+ break;
54
+ case 'query':
55
+ data = req.query;
56
+ break;
57
+ case 'params':
58
+ data = req.params;
59
+ break;
60
+ default:
61
+ return next(new Error(`Invalid validation target: ${target}`));
62
+ }
63
+ // Validate data
64
+ const result = schema.safeParse(data);
65
+ if (!result.success) {
66
+ // Validation failed
67
+ const error = result.error;
68
+ return res.status(400).json({
69
+ error: 'Validation failed',
70
+ issues: error.issues,
71
+ });
72
+ }
73
+ // Store validated data back
74
+ switch (target) {
75
+ case 'body':
76
+ req.body = result.data;
77
+ break;
78
+ case 'query':
79
+ req.query = result.data;
80
+ break;
81
+ case 'params':
82
+ req.params = result.data;
83
+ break;
84
+ }
85
+ next();
86
+ };
87
+ middleware.__visionSchema = schema;
88
+ middleware.__visionTarget = target;
89
+ return middleware;
90
+ }
91
+ /**
92
+ * Extract Zod schema from validator middleware
93
+ * Used internally by Vision to generate API docs
94
+ */
95
+ export function extractSchema(middleware) {
96
+ // This is a simplified version - in reality, we store schemas in the Map above
97
+ return undefined;
98
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@getvision/adapter-express",
3
+ "version": "0.0.0-develop-20251031183955",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts"
7
+ },
8
+ "scripts": {
9
+ "dev": "tsc --watch",
10
+ "build": "tsc",
11
+ "lint": "eslint . --max-warnings 0"
12
+ },
13
+ "license": "MIT",
14
+ "dependencies": {
15
+ "@getvision/core": "0.0.1",
16
+ "zod": "^4.1.11"
17
+ },
18
+ "peerDependencies": {
19
+ "express": "^5.0.0"
20
+ },
21
+ "devDependencies": {
22
+ "@repo/eslint-config": "0.0.0",
23
+ "@repo/typescript-config": "0.0.0",
24
+ "@types/express": "^5.0.0",
25
+ "@types/node": "^20.14.9",
26
+ "express": "^5.1.0",
27
+ "typescript": "5.9.3"
28
+ },
29
+ "config": {}
30
+ }
package/src/index.ts ADDED
@@ -0,0 +1,529 @@
1
+ import type { Request, Response, NextFunction, Application } from 'express'
2
+ import {
3
+ VisionCore,
4
+ autoDetectPackageInfo,
5
+ autoDetectIntegrations,
6
+ } from '@getvision/core'
7
+ import type { RouteMetadata, VisionExpressOptions, ServiceDefinition } from '@getvision/core'
8
+ import { AsyncLocalStorage } from 'async_hooks'
9
+ import { generateZodTemplate } from './zod-utils'
10
+
11
+ // Context storage for vision, traceId, and rootSpanId
12
+ interface VisionContext {
13
+ vision: VisionCore
14
+ traceId: string
15
+ rootSpanId: string // ID of root http.request span
16
+ }
17
+
18
+ const visionContext = new AsyncLocalStorage<VisionContext>()
19
+
20
+ /**
21
+ * Get current vision context (vision instance and traceId)
22
+ * Available in route handlers when using visionMiddleware
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * app.get('/users', (req, res) => {
27
+ * const { vision, traceId } = getVisionContext()
28
+ * // ...
29
+ * })
30
+ * ```
31
+ */
32
+ export function getVisionContext(): VisionContext {
33
+ const context = visionContext.getStore()
34
+ if (!context) {
35
+ throw new Error('Vision context not available. Make sure visionMiddleware is enabled.')
36
+ }
37
+ return context
38
+ }
39
+
40
+ /**
41
+ * Create span helper using current trace context
42
+ * Child spans will be nested under the root http.request span
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * app.get('/users', async (req, res) => {
47
+ * const withSpan = useVisionSpan()
48
+ *
49
+ * const users = withSpan('db.select', { 'db.table': 'users' }, () => {
50
+ * return db.select().from(users).all()
51
+ * })
52
+ * })
53
+ * ```
54
+ */
55
+ export function useVisionSpan() {
56
+ const { vision, traceId, rootSpanId } = getVisionContext()
57
+ const tracer = vision.getTracer()
58
+
59
+ return <T>(
60
+ name: string,
61
+ attributes: Record<string, any> = {},
62
+ fn: () => T
63
+ ): T => {
64
+ // Start child span with parentId = rootSpanId
65
+ const span = tracer.startSpan(name, traceId, rootSpanId)
66
+ console.log(`[useVisionSpan] Created span: ${name} with parentId: ${rootSpanId}`)
67
+
68
+ // Add attributes
69
+ for (const [key, value] of Object.entries(attributes)) {
70
+ tracer.setAttribute(span.id, key, value)
71
+ }
72
+
73
+ try {
74
+ const result = fn()
75
+ const completedSpan = tracer.endSpan(span.id)
76
+
77
+ // Add span to trace store
78
+ if (completedSpan) {
79
+ vision.getTraceStore().addSpan(traceId, completedSpan)
80
+ }
81
+
82
+ return result
83
+ } catch (error) {
84
+ tracer.setAttribute(span.id, 'error', true)
85
+ tracer.setAttribute(
86
+ span.id,
87
+ 'error.message',
88
+ error instanceof Error ? error.message : String(error)
89
+ )
90
+ const completedSpan = tracer.endSpan(span.id)
91
+
92
+ // Add span to trace store even on error
93
+ if (completedSpan) {
94
+ vision.getTraceStore().addSpan(traceId, completedSpan)
95
+ }
96
+
97
+ throw error
98
+ }
99
+ }
100
+ }
101
+
102
+
103
+ let visionInstance: VisionCore | null = null
104
+ const discoveredRoutes: RouteMetadata[] = []
105
+
106
+ /**
107
+ * Express middleware for Vision Dashboard
108
+ *
109
+ * @example
110
+ * ```ts
111
+ * import express from 'express'
112
+ * import { visionMiddleware } from '@getvision/adapter-express'
113
+ *
114
+ * const app = express()
115
+ *
116
+ * if (process.env.NODE_ENV === 'development') {
117
+ * app.use(visionMiddleware({ port: 9500 }))
118
+ * }
119
+ *
120
+ * app.get('/hello', (req, res) => {
121
+ * res.json({ message: 'Hello!' })
122
+ * })
123
+ *
124
+ * app.listen(3000)
125
+ * ```
126
+ */
127
+ export function visionMiddleware(options: VisionExpressOptions = {}) {
128
+ const enabled = options.enabled ?? (process.env.VISION_ENABLED !== 'false')
129
+
130
+ if (!enabled) {
131
+ return (req: Request, res: Response, next: NextFunction) => next()
132
+ }
133
+
134
+ // Initialize Vision instance
135
+ if (!visionInstance) {
136
+ visionInstance = new VisionCore({
137
+ port: options.port ?? parseInt(process.env.VISION_PORT || '9500'),
138
+ maxTraces: options.maxTraces ?? 1000,
139
+ maxLogs: options.maxLogs ?? 10000,
140
+ })
141
+
142
+ // Auto-detect service info
143
+ const pkgInfo = autoDetectPackageInfo()
144
+ const autoIntegrations = autoDetectIntegrations()
145
+
146
+ // Merge with user-provided config
147
+ const serviceName = options.service?.name || pkgInfo.name
148
+ const serviceVersion = options.service?.version || pkgInfo.version
149
+ const serviceDesc = options.service?.description
150
+ const integrations = {
151
+ ...autoIntegrations,
152
+ ...options.service?.integrations,
153
+ }
154
+
155
+ // Filter out undefined values from integrations
156
+ const cleanIntegrations: Record<string, string> = {}
157
+ for (const [key, value] of Object.entries(integrations)) {
158
+ if (value !== undefined) {
159
+ cleanIntegrations[key] = value
160
+ }
161
+ }
162
+
163
+ visionInstance.setAppStatus({
164
+ name: serviceName,
165
+ version: serviceVersion,
166
+ description: serviceDesc,
167
+ environment: process.env.NODE_ENV || 'development',
168
+ running: true,
169
+ metadata: {
170
+ framework: "Express",
171
+ integrations: Object.keys(cleanIntegrations).length > 0 ? cleanIntegrations : undefined,
172
+ }
173
+ })
174
+ }
175
+
176
+ const vision = visionInstance
177
+ const enableCors = options.cors !== false
178
+ const logging = options.logging !== false
179
+
180
+ // Return middleware function
181
+ return (req: Request, res: Response, next: NextFunction) => {
182
+ // Add CORS headers for Vision
183
+ if (enableCors) {
184
+ res.setHeader('Access-Control-Allow-Origin', '*')
185
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS')
186
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Vision-Trace-Id, X-Vision-Session')
187
+ res.setHeader('Access-Control-Expose-Headers', 'X-Vision-Trace-Id, X-Vision-Session')
188
+
189
+ // Handle preflight
190
+ if (req.method === 'OPTIONS') {
191
+ return res.status(204).end()
192
+ }
193
+ }
194
+
195
+ const startTime = Date.now()
196
+
197
+ // Create trace
198
+ const trace = vision.createTrace(req.method, req.path || req.url)
199
+
200
+ // Add trace ID to response header
201
+ res.setHeader('X-Vision-Trace-Id', trace.id)
202
+
203
+ // Start main root span for the entire request
204
+ const tracer = vision.getTracer()
205
+ const rootSpan = tracer.startSpan('http.request', trace.id)
206
+
207
+ // Add request attributes to span
208
+ tracer.setAttribute(rootSpan.id, 'http.method', req.method)
209
+ tracer.setAttribute(rootSpan.id, 'http.path', req.path || req.url)
210
+ tracer.setAttribute(rootSpan.id, 'http.url', req.originalUrl || req.url)
211
+
212
+ // Add query params if any
213
+ if (req.query && Object.keys(req.query).length > 0) {
214
+ tracer.setAttribute(rootSpan.id, 'http.query', req.query)
215
+ }
216
+
217
+ // Capture request metadata
218
+ const requestMeta = {
219
+ method: req.method,
220
+ url: req.originalUrl || req.url,
221
+ headers: req.headers,
222
+ query: Object.keys(req.query || {}).length ? req.query : undefined,
223
+ body: req.body,
224
+ }
225
+ tracer.setAttribute(rootSpan.id, 'http.request', requestMeta)
226
+ trace.metadata = { ...trace.metadata, request: requestMeta }
227
+
228
+ // Session ID tracking
229
+ const sessionId = req.headers['x-vision-session']
230
+ if (sessionId) {
231
+ tracer.setAttribute(rootSpan.id, 'session.id', sessionId)
232
+ trace.metadata = { ...trace.metadata, sessionId }
233
+ }
234
+
235
+ // Log request start if logging enabled
236
+ if (logging) {
237
+ const parts = [`method=${req.method}`, `path=${req.path || req.url}`]
238
+ if (sessionId) parts.push(`sessionId=${sessionId}`)
239
+ parts.push(`traceId=${trace.id}`)
240
+ console.info(`INF starting request ${parts.join(' ')}`)
241
+ }
242
+
243
+ // Capture response body
244
+ let responseBody: any = null
245
+ let isJsonResponse = false
246
+ const originalSend = res.send
247
+ const originalJson = res.json
248
+
249
+ res.send = function(body) {
250
+ // Only capture if not already captured by res.json
251
+ if (!isJsonResponse) {
252
+ responseBody = body
253
+ }
254
+ return originalSend.call(this, body)
255
+ }
256
+
257
+ res.json = function(body) {
258
+ // Capture the object BEFORE it's stringified
259
+ responseBody = body
260
+ isJsonResponse = true
261
+ return originalJson.call(this, body)
262
+ }
263
+
264
+ // Wrap next() to handle completion in finally block (like Hono)
265
+ const wrappedNext = () => {
266
+ try {
267
+ // Run handler in AsyncLocalStorage context with rootSpanId
268
+ visionContext.run({ vision, traceId: trace.id, rootSpanId: rootSpan.id }, () => {
269
+ next()
270
+ })
271
+ } catch (error) {
272
+ // Track error in span
273
+ tracer.addEvent(rootSpan.id, 'error', {
274
+ message: error instanceof Error ? error.message : 'Unknown error',
275
+ stack: error instanceof Error ? error.stack : undefined,
276
+ })
277
+ tracer.setAttribute(rootSpan.id, 'error', true)
278
+ throw error
279
+ }
280
+ }
281
+
282
+ // Listen for response finish to complete span
283
+ res.on('finish', () => {
284
+ try {
285
+ const duration = Date.now() - startTime
286
+
287
+ // Add response attributes
288
+ tracer.setAttribute(rootSpan.id, 'http.status_code', res.statusCode)
289
+
290
+ const responseMeta = {
291
+ status: res.statusCode,
292
+ headers: res.getHeaders(),
293
+ body: responseBody,
294
+ }
295
+ tracer.setAttribute(rootSpan.id, 'http.response', responseMeta)
296
+ trace.metadata = { ...trace.metadata, response: responseMeta }
297
+
298
+ // End span and add to trace
299
+ const completedSpan = tracer.endSpan(rootSpan.id)
300
+ if (completedSpan) {
301
+ vision.getTraceStore().addSpan(trace.id, completedSpan)
302
+ }
303
+
304
+ // Complete trace (broadcasts to dashboard)
305
+ vision.completeTrace(trace.id, res.statusCode, duration)
306
+
307
+ // Log completion
308
+ if (logging) {
309
+ console.info(
310
+ `INF request completed code=${res.statusCode} duration=${duration}ms method=${req.method} path=${req.path || req.url} traceId=${trace.id}`
311
+ )
312
+ }
313
+ } catch (error) {
314
+ console.error('Vision: Error completing trace:', error)
315
+ }
316
+ })
317
+
318
+ // Execute next
319
+ wrappedNext()
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Match route path against pattern (simple glob-like matching)
325
+ */
326
+ function matchPattern(path: string, pattern: string): boolean {
327
+ if (pattern.endsWith('/*')) {
328
+ const prefix = pattern.slice(0, -2)
329
+ return path === prefix || path.startsWith(prefix + '/')
330
+ }
331
+ return path === pattern
332
+ }
333
+
334
+ /**
335
+ * Group routes by services (auto or manual)
336
+ */
337
+ function groupRoutesByServices(
338
+ routes: RouteMetadata[],
339
+ servicesConfig?: ServiceDefinition[]
340
+ ): Record<string, { name: string; description?: string; routes: RouteMetadata[] }> {
341
+ const groups: Record<string, { name: string; description?: string; routes: RouteMetadata[] }> = {}
342
+
343
+ // Manual grouping if config provided
344
+ if (servicesConfig && servicesConfig.length > 0) {
345
+ servicesConfig.forEach((svc) => {
346
+ groups[svc.name] = { name: svc.name, description: svc.description, routes: [] }
347
+ })
348
+
349
+ groups['__uncategorized'] = { name: 'Uncategorized', routes: [] }
350
+
351
+ routes.forEach((route) => {
352
+ let matched = false
353
+ for (const svc of servicesConfig) {
354
+ if (svc.routes.some((pattern) => matchPattern(route.path, pattern))) {
355
+ groups[svc.name].routes.push(route)
356
+ matched = true
357
+ break
358
+ }
359
+ }
360
+ if (!matched) {
361
+ groups['__uncategorized'].routes.push(route)
362
+ }
363
+ })
364
+
365
+ if (groups['__uncategorized'].routes.length === 0) {
366
+ delete groups['__uncategorized']
367
+ }
368
+ } else {
369
+ // Auto-grouping: group by first path segment
370
+ groups['root'] = { name: 'Root', routes: [] }
371
+
372
+ const routesBySegment = new Map<string, RouteMetadata[]>()
373
+
374
+ for (const route of routes) {
375
+ const segments = route.path.split('/').filter(Boolean)
376
+ const serviceName = segments.length > 0 ? segments[0] : 'root'
377
+
378
+ if (!routesBySegment.has(serviceName)) {
379
+ routesBySegment.set(serviceName, [])
380
+ }
381
+ routesBySegment.get(serviceName)!.push(route)
382
+ }
383
+
384
+ for (const [serviceName, serviceRoutes] of Array.from(routesBySegment.entries())) {
385
+ const hasMultiSegment = serviceRoutes.some((r: RouteMetadata) => r.path.split('/').filter(Boolean).length > 1)
386
+
387
+ if (hasMultiSegment || serviceName === 'root') {
388
+ const capitalizedName = serviceName === 'root' ? 'Root' : serviceName.charAt(0).toUpperCase() + serviceName.slice(1)
389
+
390
+ if (serviceName === 'root') {
391
+ groups['root'].routes.push(...serviceRoutes)
392
+ } else {
393
+ groups[serviceName] = { name: capitalizedName, routes: serviceRoutes }
394
+ }
395
+ } else {
396
+ groups['root'].routes.push(...serviceRoutes)
397
+ }
398
+ }
399
+
400
+ if (groups['root'].routes.length === 0) {
401
+ delete groups['root']
402
+ }
403
+ }
404
+
405
+ return groups
406
+ }
407
+
408
+ /**
409
+ * Enable automatic route discovery for Express app
410
+ *
411
+ * @example
412
+ * ```ts
413
+ * const app = express()
414
+ * app.use(visionMiddleware())
415
+ *
416
+ * // Define routes...
417
+ * app.get('/users', handler)
418
+ * app.post('/users', handler)
419
+ *
420
+ * // Enable auto-discovery after all routes defined
421
+ * enableAutoDiscovery(app)
422
+ * ```
423
+ */
424
+ export function enableAutoDiscovery(app: Application, options?: { services?: ServiceDefinition[] }): void {
425
+ if (!visionInstance) {
426
+ console.warn('⚠️ Vision not initialized. Call visionMiddleware() first.')
427
+ return
428
+ }
429
+
430
+ const routes: RouteMetadata[] = []
431
+
432
+ // Express stores routes in app._router.stack
433
+ const router = (app as any)._router
434
+
435
+ if (!router) {
436
+ console.warn('⚠️ Express router not found')
437
+ return
438
+ }
439
+
440
+ function extractRoutes(stack: any[], basePath = '') {
441
+ stack.forEach((layer: any) => {
442
+ // Skip built-in middleware and Vision middleware
443
+ if (!layer.route && layer.name &&
444
+ ['query', 'expressInit', 'jsonParser', 'urlencodedParser', 'corsMiddleware'].includes(layer.name)) {
445
+ return
446
+ }
447
+
448
+ if (layer.route) {
449
+ // Regular route
450
+ const methods = Object.keys(layer.route.methods)
451
+ methods.forEach(method => {
452
+ const routePath = basePath + layer.route.path
453
+ const routeMethod = method.toUpperCase()
454
+
455
+ // Try to get handler name and schema from stack
456
+ let handlerName = 'anonymous'
457
+ let schema: any = undefined
458
+
459
+ if (layer.route.stack && layer.route.stack.length > 0) {
460
+ // Look for zValidator middleware with schema
461
+ for (const stackItem of layer.route.stack) {
462
+ if (stackItem.handle && (stackItem.handle as any).__visionSchema) {
463
+ schema = (stackItem.handle as any).__visionSchema
464
+ }
465
+
466
+ // Find the actual handler (last non-middleware function)
467
+ if (stackItem.name && !['bound dispatch'].includes(stackItem.name)) {
468
+ handlerName = stackItem.name
469
+ }
470
+ }
471
+ }
472
+
473
+ const route: RouteMetadata = {
474
+ method: routeMethod,
475
+ path: routePath,
476
+ handler: handlerName,
477
+ }
478
+
479
+ if (schema) {
480
+ route.schema = schema
481
+ // Generate template from Zod schema
482
+ const requestBody = generateZodTemplate(schema)
483
+ if (requestBody) {
484
+ route.requestBody = requestBody
485
+ }
486
+ }
487
+
488
+ routes.push(route)
489
+ })
490
+ } else if (layer.name === 'router' && layer.handle && layer.handle.stack) {
491
+ // Nested router - try to extract base path from regexp
492
+ let routerPath = ''
493
+
494
+ if (layer.regexp) {
495
+ const regexpSource = layer.regexp.source
496
+ // Try to extract simple path from regexp
497
+ const match = regexpSource.match(/^\^\\\/([^\\?()]+)/)
498
+ if (match) {
499
+ routerPath = '/' + match[1].replace(/\\\//g, '/')
500
+ }
501
+ }
502
+
503
+ extractRoutes(layer.handle.stack, basePath + routerPath)
504
+ }
505
+ })
506
+ }
507
+
508
+ extractRoutes(router.stack)
509
+
510
+ visionInstance.registerRoutes(routes)
511
+
512
+ // Group routes by services
513
+ const grouped = groupRoutesByServices(routes, options?.services)
514
+ const services = Object.values(grouped)
515
+ visionInstance.registerServices(services)
516
+
517
+ const schemasCount = routes.filter(r => r.schema).length
518
+ console.log(`📋 Vision: Discovered ${routes.length} routes (${services.length} services, ${schemasCount} schemas)`)
519
+ }
520
+
521
+ /**
522
+ * Get the current Vision instance
523
+ */
524
+ export function getVisionInstance(): VisionCore | null {
525
+ return visionInstance
526
+ }
527
+
528
+ // Export Zod validator for schema-based validation
529
+ export { zValidator, getRouteSchema, getAllRouteSchemas } from './zod-validator'