@getvision/adapter-hono 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/.eslintrc.cjs ADDED
@@ -0,0 +1,7 @@
1
+ module.exports = {
2
+ extends: ['@repo/eslint-config/library.js'],
3
+ parser: '@typescript-eslint/parser',
4
+ parserOptions: {
5
+ project: true,
6
+ },
7
+ }
@@ -0,0 +1 @@
1
+ $ tsc
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # @getvision/adapter-hono
2
+
3
+ Hono.js adapter for Vision Dashboard.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @getvision/adapter-hono
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Option 1: Auto-discovery (Recommended) ✨
14
+
15
+ Routes are automatically discovered - no manual registration needed!
16
+
17
+ ```typescript
18
+ import { Hono } from 'hono'
19
+ import { visionAdapter, enableAutoDiscovery } from '@getvision/adapter-hono'
20
+
21
+ const app = new Hono()
22
+
23
+ // Add Vision Dashboard (development only)
24
+ if (process.env.NODE_ENV !== 'production') {
25
+ app.use('*', visionAdapter({
26
+ port: 9500,
27
+ enabled: true,
28
+ }))
29
+
30
+ // Enable auto-discovery
31
+ enableAutoDiscovery(app)
32
+ }
33
+
34
+ // Your routes - automatically registered!
35
+ app.get('/hello', (c) => c.json({ hello: 'world' }))
36
+ app.post('/users', (c) => c.json({ success: true }))
37
+
38
+ export default app
39
+ ```
40
+
41
+ ### Option 2: Manual Registration
42
+
43
+ If you prefer explicit control:
44
+
45
+ ```typescript
46
+ import { Hono } from 'hono'
47
+ import { visionAdapter, registerRoutes } from '@getvision/adapter-hono'
48
+
49
+ const app = new Hono()
50
+
51
+ if (process.env.NODE_ENV !== 'production') {
52
+ app.use('*', visionAdapter({ port: 9500 }))
53
+ }
54
+
55
+ app.get('/hello', (c) => c.json({ hello: 'world' }))
56
+ app.post('/users', (c) => c.json({ success: true }))
57
+
58
+ // Manually register routes
59
+ if (process.env.NODE_ENV !== 'production') {
60
+ registerRoutes([
61
+ { method: 'GET', path: '/hello', handler: 'hello' },
62
+ { method: 'POST', path: '/users', handler: 'createUser' },
63
+ ])
64
+ }
65
+
66
+ export default app
67
+ ```
68
+
69
+ ## Options
70
+
71
+ ```typescript
72
+ interface VisionHonoOptions {
73
+ port?: number // Dashboard port (default: 9500)
74
+ enabled?: boolean // Enable/disable adapter (default: true)
75
+ maxTraces?: number // Max traces to store (default: 1000)
76
+ }
77
+ ```
78
+
79
+ ## Features
80
+
81
+ - ✅ **Automatic request tracing** - Every request is traced
82
+ - ✅ **Error tracking** - Exceptions are captured in traces
83
+ - ✅ **Query params** - Automatically logged
84
+ - ✅ **Response status** - Tracked in spans
85
+ - ✅ **Zero config** - Just add middleware and go!
86
+
87
+ ## How it works
88
+
89
+ The adapter:
90
+
91
+ 1. Starts Vision WebSocket server on specified port
92
+ 2. Wraps each request in a trace
93
+ 3. Creates spans with timing information
94
+ 4. Captures errors and attributes
95
+ 5. Broadcasts traces to dashboard in real-time
96
+
97
+ ## Dashboard
98
+
99
+ Once running, open `http://localhost:9500` to see:
100
+
101
+ - Real-time request traces
102
+ - Request/response details
103
+ - Error tracking
104
+ - Performance metrics
105
+
106
+ ## Example
107
+
108
+ See [examples/hono-basic](../../examples/hono) for a complete example.
@@ -0,0 +1,61 @@
1
+ import type { MiddlewareHandler } from 'hono';
2
+ import { VisionCore } from '@getvision/core';
3
+ import type { VisionHonoOptions, ServiceDefinition } from '@getvision/core';
4
+ interface VisionContext {
5
+ vision: VisionCore;
6
+ traceId: string;
7
+ }
8
+ /**
9
+ * Get current vision context (vision instance and traceId)
10
+ * Available in route handlers when using visionAdapter
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * app.get('/users', async (c) => {
15
+ * const { vision, traceId } = getVisionContext()
16
+ * const withSpan = vision.createSpanHelper(traceId)
17
+ * // ...
18
+ * })
19
+ * ```
20
+ */
21
+ export declare function getVisionContext(): VisionContext;
22
+ /**
23
+ * Create span helper using current trace context
24
+ * Shorthand for: getVisionContext() + vision.createSpanHelper()
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * app.get('/users', async (c) => {
29
+ * const withSpan = useVisionSpan()
30
+ *
31
+ * const users = withSpan('db.select', { 'db.table': 'users' }, () => {
32
+ * return db.select().from(users).all()
33
+ * })
34
+ * })
35
+ * ```
36
+ */
37
+ export declare function useVisionSpan(): <T>(name: string, attributes: Record<string, any> | undefined, fn: () => T) => T;
38
+ export declare function visionAdapter(options?: VisionHonoOptions): MiddlewareHandler;
39
+ /**
40
+ * Enable auto-discovery of routes (experimental)
41
+ * Patches Hono app methods to automatically register routes
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * const app = new Hono()
46
+ * app.use('*', visionAdapter())
47
+ * enableAutoDiscovery(app)
48
+ *
49
+ * // Routes defined after this will be auto-discovered
50
+ * app.get('/hello', (c) => c.json({ hello: 'world' }))
51
+ * ```
52
+ */
53
+ export declare function enableAutoDiscovery(app: any, options?: {
54
+ services?: ServiceDefinition[];
55
+ }): void;
56
+ /**
57
+ * Get the Vision instance (for advanced usage)
58
+ */
59
+ export declare function getVisionInstance(): VisionCore | null;
60
+ export { zValidator } from './zod-validator';
61
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAW,iBAAiB,EAAE,MAAM,MAAM,CAAA;AACtD,OAAO,EACL,UAAU,EAOX,MAAM,iBAAiB,CAAA;AACxB,OAAO,KAAK,EAAiB,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAM1F,UAAU,aAAa;IACrB,MAAM,EAAE,UAAU,CAAA;IAClB,OAAO,EAAE,MAAM,CAAA;CAChB;AAID;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,IAAI,aAAa,CAMhD;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,aAAa,qFAG5B;AAyID,wBAAgB,aAAa,CAAC,OAAO,GAAE,iBAAsB,GAAG,iBAAiB,CAwOhF;AAqGD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,GAAG,EAAE,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,iBAAiB,EAAE,CAAA;CAAE,QAGzF;AAED;;GAEG;AACH,wBAAgB,iBAAiB,IAAI,UAAU,GAAG,IAAI,CAErD;AAID,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,491 @@
1
+ import { VisionCore, generateZodTemplate, autoDetectPackageInfo, autoDetectIntegrations, detectDrizzle, startDrizzleStudio, stopDrizzleStudio, } from '@getvision/core';
2
+ import { AsyncLocalStorage } from 'async_hooks';
3
+ import { extractSchema } from './zod-validator';
4
+ const visionContext = new AsyncLocalStorage();
5
+ /**
6
+ * Get current vision context (vision instance and traceId)
7
+ * Available in route handlers when using visionAdapter
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * app.get('/users', async (c) => {
12
+ * const { vision, traceId } = getVisionContext()
13
+ * const withSpan = vision.createSpanHelper(traceId)
14
+ * // ...
15
+ * })
16
+ * ```
17
+ */
18
+ export function getVisionContext() {
19
+ const context = visionContext.getStore();
20
+ if (!context) {
21
+ throw new Error('Vision context not available. Make sure visionAdapter middleware is enabled.');
22
+ }
23
+ return context;
24
+ }
25
+ /**
26
+ * Create span helper using current trace context
27
+ * Shorthand for: getVisionContext() + vision.createSpanHelper()
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * app.get('/users', async (c) => {
32
+ * const withSpan = useVisionSpan()
33
+ *
34
+ * const users = withSpan('db.select', { 'db.table': 'users' }, () => {
35
+ * return db.select().from(users).all()
36
+ * })
37
+ * })
38
+ * ```
39
+ */
40
+ export function useVisionSpan() {
41
+ const { vision, traceId } = getVisionContext();
42
+ return vision.createSpanHelper(traceId);
43
+ }
44
+ /**
45
+ * Hono adapter for Vision Dashboard
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * import { Hono } from 'hono'
50
+ * import { visionAdapter } from '@vision/adapter-hono'
51
+ *
52
+ * const app = new Hono()
53
+ *
54
+ * if (process.env.NODE_ENV === 'development') {
55
+ * app.use('*', visionAdapter({ port: 9500 }))
56
+ * }
57
+ * ```
58
+ */
59
+ // Global Vision instance to share across middleware
60
+ let visionInstance = null;
61
+ const discoveredRoutes = [];
62
+ let registerTimer = null;
63
+ function scheduleRegistration(options) {
64
+ if (!visionInstance)
65
+ return;
66
+ if (registerTimer)
67
+ clearTimeout(registerTimer);
68
+ registerTimer = setTimeout(() => {
69
+ if (!visionInstance || discoveredRoutes.length === 0)
70
+ return;
71
+ visionInstance.registerRoutes(discoveredRoutes);
72
+ const grouped = groupRoutesByServices(discoveredRoutes, options?.services);
73
+ const services = Object.values(grouped);
74
+ visionInstance.registerServices(services);
75
+ console.log(`📋 Auto-discovered ${discoveredRoutes.length} routes (${services.length} services)`);
76
+ }, 100);
77
+ }
78
+ /**
79
+ * Auto-discover routes by patching Hono app methods
80
+ */
81
+ function patchHonoApp(app, options) {
82
+ const methods = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head'];
83
+ methods.forEach(method => {
84
+ const original = app[method];
85
+ if (original) {
86
+ app[method] = function (path, ...handlers) {
87
+ // Try to extract Zod schema from zValidator middleware
88
+ let requestBodySchema = undefined;
89
+ for (const handler of handlers) {
90
+ const schema = extractSchema(handler);
91
+ if (schema) {
92
+ requestBodySchema = generateZodTemplate(schema);
93
+ break;
94
+ }
95
+ }
96
+ // Register route with Vision
97
+ discoveredRoutes.push({
98
+ method: method.toUpperCase(),
99
+ path,
100
+ handler: handlers[handlers.length - 1]?.name || 'anonymous',
101
+ middleware: [],
102
+ requestBody: requestBodySchema,
103
+ });
104
+ // Call original method
105
+ const result = original.call(this, path, ...handlers);
106
+ scheduleRegistration(options);
107
+ return result;
108
+ };
109
+ }
110
+ });
111
+ // Patch routing of child apps to capture their routes with base prefix
112
+ const originalRoute = app.route;
113
+ if (originalRoute && typeof originalRoute === 'function') {
114
+ app.route = function (base, child) {
115
+ // Attempt to read child routes and register them with base prefix
116
+ try {
117
+ const routes = child?.routes;
118
+ if (Array.isArray(routes)) {
119
+ for (const r of routes) {
120
+ const method = (r?.method || r?.methods?.[0] || '').toString().toUpperCase() || 'GET';
121
+ const rawPath = r?.path || r?.pattern?.path || r?.pattern || '/';
122
+ const childPath = typeof rawPath === 'string' ? rawPath : '/';
123
+ const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
124
+ const normalizedChild = childPath === '/' ? '' : (childPath.startsWith('/') ? childPath : `/${childPath}`);
125
+ const fullPath = `${normalizedBase}${normalizedChild}` || '/';
126
+ discoveredRoutes.push({
127
+ method,
128
+ path: fullPath,
129
+ handler: r?.handler?.name || 'anonymous',
130
+ middleware: [],
131
+ });
132
+ }
133
+ scheduleRegistration(options);
134
+ }
135
+ }
136
+ catch { }
137
+ return originalRoute.call(this, base, child);
138
+ };
139
+ }
140
+ }
141
+ /**
142
+ * Resolve endpoint template (e.g. /users/:id) for a concrete path
143
+ */
144
+ function resolveEndpointTemplate(method, concretePath) {
145
+ const candidates = discoveredRoutes.filter((r) => r.method === method.toUpperCase());
146
+ for (const r of candidates) {
147
+ const pattern = '^' + r.path.replace(/:[^/]+/g, '[^/]+') + '$';
148
+ const re = new RegExp(pattern);
149
+ if (re.test(concretePath)) {
150
+ return { endpoint: r.path, handler: r.handler || 'anonymous' };
151
+ }
152
+ }
153
+ return { endpoint: concretePath, handler: 'anonymous' };
154
+ }
155
+ /**
156
+ * Extract params by comparing template path with concrete path
157
+ * e.g. template=/users/:id, concrete=/users/123 => { id: '123' }
158
+ */
159
+ function extractParams(template, concrete) {
160
+ const tParts = template.split('/').filter(Boolean);
161
+ const cParts = concrete.split('/').filter(Boolean);
162
+ if (tParts.length !== cParts.length)
163
+ return undefined;
164
+ const result = {};
165
+ tParts.forEach((seg, i) => {
166
+ if (seg.startsWith(':')) {
167
+ result[seg.slice(1)] = cParts[i];
168
+ }
169
+ });
170
+ return Object.keys(result).length ? result : undefined;
171
+ }
172
+ export function visionAdapter(options = {}) {
173
+ const { enabled = true, port = 9500, maxTraces = 1000, logging = true } = options;
174
+ if (!enabled) {
175
+ return async (c, next) => await next();
176
+ }
177
+ // Initialize Vision Core once
178
+ if (!visionInstance) {
179
+ visionInstance = new VisionCore({ port, maxTraces });
180
+ // Auto-detect service info
181
+ const pkgInfo = autoDetectPackageInfo();
182
+ const autoIntegrations = autoDetectIntegrations();
183
+ // Merge with user-provided config
184
+ const serviceName = options.service?.name || options.name || pkgInfo.name;
185
+ const serviceVersion = options.service?.version || pkgInfo.version;
186
+ const serviceDesc = options.service?.description;
187
+ const integrations = {
188
+ ...autoIntegrations,
189
+ ...options.service?.integrations,
190
+ };
191
+ // Filter out undefined values from integrations
192
+ const cleanIntegrations = {};
193
+ for (const [key, value] of Object.entries(integrations)) {
194
+ if (value !== undefined) {
195
+ cleanIntegrations[key] = value;
196
+ }
197
+ }
198
+ // Detect and optionally start Drizzle Studio
199
+ const drizzleInfo = detectDrizzle();
200
+ let drizzleStudioUrl;
201
+ if (drizzleInfo.detected) {
202
+ console.log(`🗄️ Drizzle detected (${drizzleInfo.configPath})`);
203
+ if (options.drizzle?.autoStart) {
204
+ const drizzlePort = options.drizzle.port || 4983;
205
+ const started = startDrizzleStudio(drizzlePort);
206
+ if (started) {
207
+ // Drizzle Studio uses local.drizzle.studio domain (with HTTPS)
208
+ drizzleStudioUrl = 'https://local.drizzle.studio';
209
+ }
210
+ }
211
+ else {
212
+ console.log('💡 Tip: Enable Drizzle Studio auto-start with drizzle: { autoStart: true }');
213
+ drizzleStudioUrl = 'https://local.drizzle.studio';
214
+ }
215
+ }
216
+ // Set app status with service metadata
217
+ visionInstance.setAppStatus({
218
+ name: serviceName,
219
+ version: serviceVersion,
220
+ description: serviceDesc,
221
+ running: true,
222
+ pid: process.pid,
223
+ metadata: {
224
+ framework: 'hono',
225
+ integrations: Object.keys(cleanIntegrations).length > 0 ? cleanIntegrations : undefined,
226
+ drizzle: drizzleInfo.detected
227
+ ? {
228
+ detected: true,
229
+ configPath: drizzleInfo.configPath,
230
+ studioUrl: drizzleStudioUrl,
231
+ autoStarted: options.drizzle?.autoStart || false,
232
+ }
233
+ : undefined,
234
+ },
235
+ });
236
+ // Cleanup on exit
237
+ process.on('SIGINT', () => {
238
+ stopDrizzleStudio();
239
+ process.exit(0);
240
+ });
241
+ process.on('SIGTERM', () => {
242
+ stopDrizzleStudio();
243
+ process.exit(0);
244
+ });
245
+ }
246
+ const vision = visionInstance;
247
+ // Middleware to trace requests
248
+ return async (c, next) => {
249
+ // Skip tracing for OPTIONS requests (CORS preflight)
250
+ if (c.req.method === 'OPTIONS') {
251
+ return next();
252
+ }
253
+ const startTime = Date.now();
254
+ // Create trace
255
+ const trace = vision.createTrace(c.req.method, c.req.path);
256
+ // Run request in AsyncLocalStorage context
257
+ return visionContext.run({ vision, traceId: trace.id }, async () => {
258
+ // Also add to Hono context for compatibility
259
+ c.set('vision', vision);
260
+ c.set('traceId', trace.id);
261
+ // Start main span
262
+ const tracer = vision.getTracer();
263
+ const span = tracer.startSpan('http.request', trace.id);
264
+ // Add request attributes
265
+ tracer.setAttribute(span.id, 'http.method', c.req.method);
266
+ tracer.setAttribute(span.id, 'http.path', c.req.path);
267
+ tracer.setAttribute(span.id, 'http.url', c.req.url);
268
+ // Add query params if any
269
+ const url = new URL(c.req.url);
270
+ if (url.search) {
271
+ tracer.setAttribute(span.id, 'http.query', url.search);
272
+ }
273
+ // Capture request metadata (headers, query, body if json)
274
+ try {
275
+ const rawReq = c.req.raw;
276
+ const headers = {};
277
+ rawReq.headers.forEach((v, k) => { headers[k] = v; });
278
+ const urlObj = new URL(c.req.url);
279
+ const query = {};
280
+ urlObj.searchParams.forEach((v, k) => { query[k] = v; });
281
+ let body = undefined;
282
+ const ct = headers['content-type'] || headers['Content-Type'];
283
+ if (ct && ct.includes('application/json')) {
284
+ try {
285
+ body = await rawReq.clone().json();
286
+ }
287
+ catch { }
288
+ }
289
+ const sessionId = headers['x-vision-session'];
290
+ if (sessionId) {
291
+ tracer.setAttribute(span.id, 'session.id', sessionId);
292
+ trace.metadata = { ...(trace.metadata || {}), sessionId };
293
+ }
294
+ const requestMeta = {
295
+ method: c.req.method,
296
+ url: urlObj.pathname + (urlObj.search || ''),
297
+ headers,
298
+ query: Object.keys(query).length ? query : undefined,
299
+ body,
300
+ };
301
+ tracer.setAttribute(span.id, 'http.request', requestMeta);
302
+ // Also mirror to trace-level metadata for convenience
303
+ trace.metadata = { ...(trace.metadata || {}), request: requestMeta };
304
+ // Emit start log (if enabled)
305
+ if (logging) {
306
+ const { endpoint, handler } = resolveEndpointTemplate(c.req.method, c.req.path);
307
+ const params = extractParams(endpoint, c.req.path);
308
+ const parts = [
309
+ `method=${c.req.method}`,
310
+ `endpoint=${endpoint}`,
311
+ `service=${handler}`,
312
+ ];
313
+ if (params)
314
+ parts.push(`params=${JSON.stringify(params)}`);
315
+ if (Object.keys(query).length)
316
+ parts.push(`query=${JSON.stringify(query)}`);
317
+ if (trace.metadata?.sessionId)
318
+ parts.push(`sessionId=${trace.metadata.sessionId}`);
319
+ parts.push(`traceId=${trace.id}`);
320
+ console.info(`INF starting request ${parts.join(' ')}`);
321
+ }
322
+ // Execute request
323
+ await next();
324
+ // Add response attributes
325
+ tracer.setAttribute(span.id, 'http.status_code', c.res.status);
326
+ const resHeaders = {};
327
+ c.res.headers?.forEach((v, k) => { resHeaders[k] = v; });
328
+ let respBody = undefined;
329
+ const resCt = c.res.headers?.get('content-type') || '';
330
+ try {
331
+ const clone = c.res.clone();
332
+ if (resCt.includes('application/json')) {
333
+ const txt = await clone.text();
334
+ if (txt && txt.length <= 65536) {
335
+ try {
336
+ respBody = JSON.parse(txt);
337
+ }
338
+ catch {
339
+ respBody = txt;
340
+ }
341
+ }
342
+ }
343
+ }
344
+ catch { }
345
+ const responseMeta = {
346
+ status: c.res.status,
347
+ headers: Object.keys(resHeaders).length ? resHeaders : undefined,
348
+ body: respBody,
349
+ };
350
+ tracer.setAttribute(span.id, 'http.response', responseMeta);
351
+ trace.metadata = { ...(trace.metadata || {}), response: responseMeta };
352
+ }
353
+ catch (error) {
354
+ // Track error
355
+ tracer.addEvent(span.id, 'error', {
356
+ message: error instanceof Error ? error.message : 'Unknown error',
357
+ stack: error instanceof Error ? error.stack : undefined,
358
+ });
359
+ tracer.setAttribute(span.id, 'error', true);
360
+ throw error;
361
+ }
362
+ finally {
363
+ // End span and add it to trace
364
+ const completedSpan = tracer.endSpan(span.id);
365
+ if (completedSpan) {
366
+ vision.getTraceStore().addSpan(trace.id, completedSpan);
367
+ }
368
+ // Complete trace
369
+ const duration = Date.now() - startTime;
370
+ vision.completeTrace(trace.id, c.res.status, duration);
371
+ // Add trace ID to response headers so client can correlate metrics
372
+ c.header('X-Vision-Trace-Id', trace.id);
373
+ // Emit completion log (if enabled)
374
+ if (logging) {
375
+ const { endpoint } = resolveEndpointTemplate(c.req.method, c.req.path);
376
+ console.info(`INF request completed code=${c.res.status} duration=${duration}ms method=${c.req.method} endpoint=${endpoint} traceId=${trace.id}`);
377
+ }
378
+ }
379
+ }); // Close visionContext.run()
380
+ };
381
+ }
382
+ /**
383
+ * Match route path against pattern (simple glob-like matching)
384
+ * e.g., '/users/*' matches '/users/list', '/users/123'
385
+ */
386
+ function matchPattern(path, pattern) {
387
+ if (pattern.endsWith('/*')) {
388
+ const prefix = pattern.slice(0, -2);
389
+ return path === prefix || path.startsWith(prefix + '/');
390
+ }
391
+ return path === pattern;
392
+ }
393
+ /**
394
+ * Group routes by services (auto or manual)
395
+ */
396
+ function groupRoutesByServices(routes, servicesConfig) {
397
+ const groups = {};
398
+ // Manual grouping if config provided
399
+ if (servicesConfig && servicesConfig.length > 0) {
400
+ // Initialize groups from config
401
+ servicesConfig.forEach((svc) => {
402
+ groups[svc.name] = { name: svc.name, description: svc.description, routes: [] };
403
+ });
404
+ // Uncategorized group
405
+ groups['__uncategorized'] = { name: 'Uncategorized', routes: [] };
406
+ // Assign routes to services
407
+ routes.forEach((route) => {
408
+ let matched = false;
409
+ for (const svc of servicesConfig) {
410
+ if (svc.routes.some((pattern) => matchPattern(route.path, pattern))) {
411
+ groups[svc.name].routes.push(route);
412
+ matched = true;
413
+ break;
414
+ }
415
+ }
416
+ if (!matched) {
417
+ groups['__uncategorized'].routes.push(route);
418
+ }
419
+ });
420
+ // Remove empty uncategorized
421
+ if (groups['__uncategorized'].routes.length === 0) {
422
+ delete groups['__uncategorized'];
423
+ }
424
+ }
425
+ else {
426
+ // Auto-grouping: group by first path segment
427
+ // If a resource has any subpaths, all its routes go to that service
428
+ // First pass: collect all routes by first segment
429
+ const routesBySegment = new Map();
430
+ for (const route of routes) {
431
+ const segments = route.path.split('/').filter(Boolean);
432
+ const serviceName = segments.length > 0 ? segments[0] : 'root';
433
+ if (!routesBySegment.has(serviceName)) {
434
+ routesBySegment.set(serviceName, []);
435
+ }
436
+ routesBySegment.get(serviceName).push(route);
437
+ }
438
+ // Second pass: if a service has any multi-segment routes, keep it as a service
439
+ // Otherwise move single-segment routes to Root
440
+ groups['root'] = { name: 'Root', routes: [] };
441
+ for (const [serviceName, serviceRoutes] of routesBySegment) {
442
+ const hasMultiSegment = serviceRoutes.some(r => r.path.split('/').filter(Boolean).length > 1);
443
+ if (hasMultiSegment || serviceName === 'root') {
444
+ // This is a real service with subpaths, or it's root
445
+ const capitalizedName = serviceName === 'root' ? 'Root' : serviceName.charAt(0).toUpperCase() + serviceName.slice(1);
446
+ if (serviceName === 'root') {
447
+ groups['root'].routes.push(...serviceRoutes);
448
+ }
449
+ else {
450
+ groups[serviceName] = { name: capitalizedName, routes: serviceRoutes };
451
+ }
452
+ }
453
+ else {
454
+ // Single-segment route with no siblings → goes to Root
455
+ groups['root'].routes.push(...serviceRoutes);
456
+ }
457
+ }
458
+ // Remove Root if empty
459
+ if (groups['root'].routes.length === 0) {
460
+ delete groups['root'];
461
+ }
462
+ }
463
+ return groups;
464
+ }
465
+ /**
466
+ * Enable auto-discovery of routes (experimental)
467
+ * Patches Hono app methods to automatically register routes
468
+ *
469
+ * @example
470
+ * ```ts
471
+ * const app = new Hono()
472
+ * app.use('*', visionAdapter())
473
+ * enableAutoDiscovery(app)
474
+ *
475
+ * // Routes defined after this will be auto-discovered
476
+ * app.get('/hello', (c) => c.json({ hello: 'world' }))
477
+ * ```
478
+ */
479
+ export function enableAutoDiscovery(app, options) {
480
+ patchHonoApp(app, options);
481
+ scheduleRegistration(options);
482
+ }
483
+ /**
484
+ * Get the Vision instance (for advanced usage)
485
+ */
486
+ export function getVisionInstance() {
487
+ return visionInstance;
488
+ }
489
+ // Re-export monkey-patched zValidator that stores schema for Vision introspection
490
+ // Use this instead of @hono/zod-validator to enable automatic schema detection
491
+ export { zValidator } from './zod-validator';
@@ -0,0 +1,15 @@
1
+ import type { ZodType } from 'zod';
2
+ /**
3
+ * Monkey-patch zValidator to attach schema for Vision introspection
4
+ */
5
+ export declare const zValidator: <T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> | import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>>, Target extends keyof import("hono").ValidationTargets, E extends import("hono").Env, P extends string, In = T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").input<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").input<T> : never, Out = T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").output<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").infer<T> : never, I extends import("hono").Input = {
6
+ in: (undefined extends In ? true : false) extends true ? { [K in Target]?: In extends import("hono").ValidationTargets[K] ? In : { [K2 in keyof In]?: In[K2] extends import("hono").ValidationTargets[K][K2] ? In[K2] : import("hono").ValidationTargets[K][K2]; }; } : { [K in Target]: In extends import("hono").ValidationTargets[K] ? In : { [K2 in keyof In]: In[K2] extends import("hono").ValidationTargets[K][K2] ? In[K2] : import("hono").ValidationTargets[K][K2]; }; };
7
+ out: { [K in Target]: Out; };
8
+ }, V extends I = I, InferredValue = T extends import("zod/v3").ZodType<any, import("zod/v3").ZodTypeDef, any> ? import("zod/v3").TypeOf<T> : T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").infer<T> : never>(target: Target, schema: T, hook?: import("@hono/zod-validator").Hook<InferredValue, E, P, Target, {}, T>, options?: {
9
+ validationFunction: (schema: T, value: import("hono").ValidationTargets[Target]) => (T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").ZodSafeParseResult<any> : import("zod/v3").SafeParseReturnType<any, any>) | Promise<T extends import("zod/v4/core").$ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>> ? import("zod").ZodSafeParseResult<any> : import("zod/v3").SafeParseReturnType<any, any>>;
10
+ }) => import("hono").MiddlewareHandler<E, P, V>;
11
+ /**
12
+ * Extract schema from validator middleware
13
+ */
14
+ export declare function extractSchema(validator: any): ZodType | undefined;
15
+ //# sourceMappingURL=zod-validator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"zod-validator.d.ts","sourceRoot":"","sources":["../src/zod-validator.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,KAAK,CAAA;AAElC;;GAEG;AACH,eAAO,MAAM,UAAU;4EAqBgrB,CAAC,0EAA2D,CAAC,iBAAgB,mCAAqB,gBAAe,mCAAqB,uHAAuG,mCAAqB,gBAAe,mCAAqB;;;;+CAR3+B,CAAA;AAEF;;GAEG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,GAAG,GAAG,OAAO,GAAG,SAAS,CAEjE"}
@@ -0,0 +1,23 @@
1
+ import { zValidator as originalZValidator } from '@hono/zod-validator';
2
+ /**
3
+ * Monkey-patch zValidator to attach schema for Vision introspection
4
+ */
5
+ export const zValidator = new Proxy(originalZValidator, {
6
+ apply(target, thisArg, args) {
7
+ // Call original zValidator
8
+ const validator = Reflect.apply(target, thisArg, args);
9
+ // Attach schema (2nd argument) to the returned middleware handler
10
+ const schema = args[1];
11
+ if (schema && typeof schema === 'object' && '_def' in schema) {
12
+ ;
13
+ validator.__visionSchema = schema;
14
+ }
15
+ return validator;
16
+ }
17
+ });
18
+ /**
19
+ * Extract schema from validator middleware
20
+ */
21
+ export function extractSchema(validator) {
22
+ return validator?.__visionSchema;
23
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@getvision/adapter-hono",
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
+ "@hono/zod-validator": "^0.7.3",
16
+ "@getvision/core": "0.0.1",
17
+ "zod": "^4.1.11"
18
+ },
19
+ "peerDependencies": {
20
+ "hono": "^4.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@repo/eslint-config": "0.0.0",
24
+ "@repo/typescript-config": "0.0.0",
25
+ "@types/node": "^20.14.9",
26
+ "hono": "^4.6.14",
27
+ "typescript": "5.9.3"
28
+ },
29
+ "config": {}
30
+ }
package/src/index.ts ADDED
@@ -0,0 +1,561 @@
1
+ import type { Context, MiddlewareHandler } from 'hono'
2
+ import {
3
+ VisionCore,
4
+ generateZodTemplate,
5
+ autoDetectPackageInfo,
6
+ autoDetectIntegrations,
7
+ detectDrizzle,
8
+ startDrizzleStudio,
9
+ stopDrizzleStudio,
10
+ } from '@getvision/core'
11
+ import type { RouteMetadata, VisionHonoOptions, ServiceDefinition } from '@getvision/core'
12
+ import { existsSync } from 'fs'
13
+ import { AsyncLocalStorage } from 'async_hooks'
14
+ import { extractSchema } from './zod-validator'
15
+
16
+ // Context storage for vision and traceId
17
+ interface VisionContext {
18
+ vision: VisionCore
19
+ traceId: string
20
+ }
21
+
22
+ const visionContext = new AsyncLocalStorage<VisionContext>()
23
+
24
+ /**
25
+ * Get current vision context (vision instance and traceId)
26
+ * Available in route handlers when using visionAdapter
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * app.get('/users', async (c) => {
31
+ * const { vision, traceId } = getVisionContext()
32
+ * const withSpan = vision.createSpanHelper(traceId)
33
+ * // ...
34
+ * })
35
+ * ```
36
+ */
37
+ export function getVisionContext(): VisionContext {
38
+ const context = visionContext.getStore()
39
+ if (!context) {
40
+ throw new Error('Vision context not available. Make sure visionAdapter middleware is enabled.')
41
+ }
42
+ return context
43
+ }
44
+
45
+ /**
46
+ * Create span helper using current trace context
47
+ * Shorthand for: getVisionContext() + vision.createSpanHelper()
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * app.get('/users', async (c) => {
52
+ * const withSpan = useVisionSpan()
53
+ *
54
+ * const users = withSpan('db.select', { 'db.table': 'users' }, () => {
55
+ * return db.select().from(users).all()
56
+ * })
57
+ * })
58
+ * ```
59
+ */
60
+ export function useVisionSpan() {
61
+ const { vision, traceId } = getVisionContext()
62
+ return vision.createSpanHelper(traceId)
63
+ }
64
+
65
+
66
+ /**
67
+ * Hono adapter for Vision Dashboard
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * import { Hono } from 'hono'
72
+ * import { visionAdapter } from '@vision/adapter-hono'
73
+ *
74
+ * const app = new Hono()
75
+ *
76
+ * if (process.env.NODE_ENV === 'development') {
77
+ * app.use('*', visionAdapter({ port: 9500 }))
78
+ * }
79
+ * ```
80
+ */
81
+ // Global Vision instance to share across middleware
82
+ let visionInstance: VisionCore | null = null
83
+ const discoveredRoutes: RouteMetadata[] = []
84
+ let registerTimer: NodeJS.Timeout | null = null
85
+
86
+ function scheduleRegistration(options?: { services?: ServiceDefinition[] }) {
87
+ if (!visionInstance) return
88
+ if (registerTimer) clearTimeout(registerTimer)
89
+ registerTimer = setTimeout(() => {
90
+ if (!visionInstance || discoveredRoutes.length === 0) return
91
+ visionInstance.registerRoutes(discoveredRoutes)
92
+ const grouped = groupRoutesByServices(discoveredRoutes, options?.services)
93
+ const services = Object.values(grouped)
94
+ visionInstance.registerServices(services)
95
+ console.log(`📋 Auto-discovered ${discoveredRoutes.length} routes (${services.length} services)`)
96
+ }, 100)
97
+ }
98
+
99
+
100
+ /**
101
+ * Auto-discover routes by patching Hono app methods
102
+ */
103
+ function patchHonoApp(app: any, options?: { services?: ServiceDefinition[] }) {
104
+ const methods = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head']
105
+
106
+ methods.forEach(method => {
107
+ const original = app[method]
108
+ if (original) {
109
+ app[method] = function(path: string, ...handlers: any[]) {
110
+ // Try to extract Zod schema from zValidator middleware
111
+ let requestBodySchema = undefined
112
+
113
+ for (const handler of handlers) {
114
+ const schema = extractSchema(handler)
115
+ if (schema) {
116
+ requestBodySchema = generateZodTemplate(schema)
117
+ break
118
+ }
119
+ }
120
+
121
+ // Register route with Vision
122
+ discoveredRoutes.push({
123
+ method: method.toUpperCase(),
124
+ path,
125
+ handler: handlers[handlers.length - 1]?.name || 'anonymous',
126
+ middleware: [],
127
+ requestBody: requestBodySchema,
128
+ })
129
+
130
+ // Call original method
131
+ const result = original.call(this, path, ...handlers)
132
+ scheduleRegistration(options)
133
+ return result
134
+ }
135
+ }
136
+ })
137
+
138
+ // Patch routing of child apps to capture their routes with base prefix
139
+ const originalRoute = app.route
140
+ if (originalRoute && typeof originalRoute === 'function') {
141
+ app.route = function(base: string, child: any) {
142
+ // Attempt to read child routes and register them with base prefix
143
+ try {
144
+ const routes = (child as any)?.routes
145
+ if (Array.isArray(routes)) {
146
+ for (const r of routes) {
147
+ const method = (r?.method || r?.methods?.[0] || '').toString().toUpperCase() || 'GET'
148
+ const rawPath = r?.path || r?.pattern?.path || r?.pattern || '/'
149
+ const childPath = typeof rawPath === 'string' ? rawPath : '/'
150
+ const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base
151
+ const normalizedChild = childPath === '/' ? '' : (childPath.startsWith('/') ? childPath : `/${childPath}`)
152
+ const fullPath = `${normalizedBase}${normalizedChild}` || '/'
153
+ discoveredRoutes.push({
154
+ method,
155
+ path: fullPath,
156
+ handler: r?.handler?.name || 'anonymous',
157
+ middleware: [],
158
+ })
159
+ }
160
+ scheduleRegistration(options)
161
+ }
162
+ } catch {}
163
+ return originalRoute.call(this, base, child)
164
+ }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Resolve endpoint template (e.g. /users/:id) for a concrete path
170
+ */
171
+ function resolveEndpointTemplate(method: string, concretePath: string): { endpoint: string; handler: string } {
172
+ const candidates = discoveredRoutes.filter((r) => r.method === method.toUpperCase())
173
+ for (const r of candidates) {
174
+ const pattern = '^' + r.path.replace(/:[^/]+/g, '[^/]+') + '$'
175
+ const re = new RegExp(pattern)
176
+ if (re.test(concretePath)) {
177
+ return { endpoint: r.path, handler: r.handler || 'anonymous' }
178
+ }
179
+ }
180
+ return { endpoint: concretePath, handler: 'anonymous' }
181
+ }
182
+
183
+ /**
184
+ * Extract params by comparing template path with concrete path
185
+ * e.g. template=/users/:id, concrete=/users/123 => { id: '123' }
186
+ */
187
+ function extractParams(template: string, concrete: string): Record<string, string> | undefined {
188
+ const tParts = template.split('/').filter(Boolean)
189
+ const cParts = concrete.split('/').filter(Boolean)
190
+ if (tParts.length !== cParts.length) return undefined
191
+ const result: Record<string, string> = {}
192
+ tParts.forEach((seg, i) => {
193
+ if (seg.startsWith(':')) {
194
+ result[seg.slice(1)] = cParts[i]
195
+ }
196
+ })
197
+ return Object.keys(result).length ? result : undefined
198
+ }
199
+
200
+ export function visionAdapter(options: VisionHonoOptions = {}): MiddlewareHandler {
201
+ const { enabled = true, port = 9500, maxTraces = 1000, logging = true } = options
202
+
203
+ if (!enabled) {
204
+ return async (c, next) => await next()
205
+ }
206
+
207
+ // Initialize Vision Core once
208
+ if (!visionInstance) {
209
+ visionInstance = new VisionCore({ port, maxTraces })
210
+
211
+ // Auto-detect service info
212
+ const pkgInfo = autoDetectPackageInfo()
213
+ const autoIntegrations = autoDetectIntegrations()
214
+
215
+ // Merge with user-provided config
216
+ const serviceName = options.service?.name || options.name || pkgInfo.name
217
+ const serviceVersion = options.service?.version || pkgInfo.version
218
+ const serviceDesc = options.service?.description
219
+ const integrations = {
220
+ ...autoIntegrations,
221
+ ...options.service?.integrations,
222
+ }
223
+
224
+ // Filter out undefined values from integrations
225
+ const cleanIntegrations: Record<string, string> = {}
226
+ for (const [key, value] of Object.entries(integrations)) {
227
+ if (value !== undefined) {
228
+ cleanIntegrations[key] = value
229
+ }
230
+ }
231
+
232
+ // Detect and optionally start Drizzle Studio
233
+ const drizzleInfo = detectDrizzle()
234
+ let drizzleStudioUrl: string | undefined
235
+
236
+ if (drizzleInfo.detected) {
237
+ console.log(`🗄️ Drizzle detected (${drizzleInfo.configPath})`)
238
+
239
+ if (options.drizzle?.autoStart) {
240
+ const drizzlePort = options.drizzle.port || 4983
241
+ const started = startDrizzleStudio(drizzlePort)
242
+ if (started) {
243
+ // Drizzle Studio uses local.drizzle.studio domain (with HTTPS)
244
+ drizzleStudioUrl = 'https://local.drizzle.studio'
245
+ }
246
+ } else {
247
+ console.log('💡 Tip: Enable Drizzle Studio auto-start with drizzle: { autoStart: true }')
248
+ drizzleStudioUrl = 'https://local.drizzle.studio'
249
+ }
250
+ }
251
+
252
+ // Set app status with service metadata
253
+ visionInstance.setAppStatus({
254
+ name: serviceName,
255
+ version: serviceVersion,
256
+ description: serviceDesc,
257
+ running: true,
258
+ pid: process.pid,
259
+ metadata: {
260
+ framework: 'hono',
261
+ integrations: Object.keys(cleanIntegrations).length > 0 ? cleanIntegrations : undefined,
262
+ drizzle: drizzleInfo.detected
263
+ ? {
264
+ detected: true,
265
+ configPath: drizzleInfo.configPath,
266
+ studioUrl: drizzleStudioUrl,
267
+ autoStarted: options.drizzle?.autoStart || false,
268
+ }
269
+ : undefined,
270
+ },
271
+ })
272
+
273
+ // Cleanup on exit
274
+ process.on('SIGINT', () => {
275
+ stopDrizzleStudio()
276
+ process.exit(0)
277
+ })
278
+
279
+ process.on('SIGTERM', () => {
280
+ stopDrizzleStudio()
281
+ process.exit(0)
282
+ })
283
+ }
284
+
285
+ const vision = visionInstance
286
+
287
+ // Middleware to trace requests
288
+ return async (c: Context, next) => {
289
+ // Skip tracing for OPTIONS requests (CORS preflight)
290
+ if (c.req.method === 'OPTIONS') {
291
+ return next()
292
+ }
293
+
294
+ const startTime = Date.now()
295
+
296
+ // Create trace
297
+ const trace = vision.createTrace(c.req.method, c.req.path)
298
+
299
+ // Run request in AsyncLocalStorage context
300
+ return visionContext.run({ vision, traceId: trace.id }, async () => {
301
+ // Also add to Hono context for compatibility
302
+ c.set('vision', vision)
303
+ c.set('traceId', trace.id)
304
+
305
+ // Start main span
306
+ const tracer = vision.getTracer()
307
+ const span = tracer.startSpan('http.request', trace.id)
308
+
309
+ // Add request attributes
310
+ tracer.setAttribute(span.id, 'http.method', c.req.method)
311
+ tracer.setAttribute(span.id, 'http.path', c.req.path)
312
+ tracer.setAttribute(span.id, 'http.url', c.req.url)
313
+
314
+ // Add query params if any
315
+ const url = new URL(c.req.url)
316
+ if (url.search) {
317
+ tracer.setAttribute(span.id, 'http.query', url.search)
318
+ }
319
+
320
+ // Capture request metadata (headers, query, body if json)
321
+ try {
322
+ const rawReq = c.req.raw
323
+ const headers: Record<string, string> = {}
324
+ rawReq.headers.forEach((v, k) => { headers[k] = v })
325
+ const urlObj = new URL(c.req.url)
326
+ const query: Record<string, string> = {}
327
+ urlObj.searchParams.forEach((v, k) => { query[k] = v })
328
+
329
+ let body: unknown = undefined
330
+ const ct = headers['content-type'] || headers['Content-Type']
331
+ if (ct && ct.includes('application/json')) {
332
+ try {
333
+ body = await rawReq.clone().json()
334
+ } catch {}
335
+ }
336
+
337
+ const sessionId = headers['x-vision-session']
338
+ if (sessionId) {
339
+ tracer.setAttribute(span.id, 'session.id', sessionId)
340
+ trace.metadata = { ...(trace.metadata || {}), sessionId }
341
+ }
342
+
343
+ const requestMeta = {
344
+ method: c.req.method,
345
+ url: urlObj.pathname + (urlObj.search || ''),
346
+ headers,
347
+ query: Object.keys(query).length ? query : undefined,
348
+ body,
349
+ }
350
+ tracer.setAttribute(span.id, 'http.request', requestMeta)
351
+ // Also mirror to trace-level metadata for convenience
352
+ trace.metadata = { ...(trace.metadata || {}), request: requestMeta }
353
+
354
+ // Emit start log (if enabled)
355
+ if (logging) {
356
+ const { endpoint, handler } = resolveEndpointTemplate(c.req.method, c.req.path)
357
+ const params = extractParams(endpoint, c.req.path)
358
+ const parts = [
359
+ `method=${c.req.method}`,
360
+ `endpoint=${endpoint}`,
361
+ `service=${handler}`,
362
+ ]
363
+ if (params) parts.push(`params=${JSON.stringify(params)}`)
364
+ if (Object.keys(query).length) parts.push(`query=${JSON.stringify(query)}`)
365
+ if ((trace.metadata as any)?.sessionId) parts.push(`sessionId=${(trace.metadata as any).sessionId}`)
366
+ parts.push(`traceId=${trace.id}`)
367
+ console.info(`INF starting request ${parts.join(' ')}`)
368
+ }
369
+
370
+ // Execute request
371
+ await next()
372
+
373
+ // Add response attributes
374
+ tracer.setAttribute(span.id, 'http.status_code', c.res.status)
375
+ const resHeaders: Record<string, string> = {}
376
+ c.res.headers?.forEach((v, k) => { resHeaders[k] = v as unknown as string })
377
+
378
+ let respBody: unknown = undefined
379
+ const resCt = c.res.headers?.get('content-type') || ''
380
+ try {
381
+ const clone = c.res.clone()
382
+ if (resCt.includes('application/json')) {
383
+ const txt = await clone.text()
384
+ if (txt && txt.length <= 65536) {
385
+ try { respBody = JSON.parse(txt) } catch { respBody = txt }
386
+ }
387
+ }
388
+ } catch {}
389
+
390
+ const responseMeta = {
391
+ status: c.res.status,
392
+ headers: Object.keys(resHeaders).length ? resHeaders : undefined,
393
+ body: respBody,
394
+ }
395
+ tracer.setAttribute(span.id, 'http.response', responseMeta)
396
+ trace.metadata = { ...(trace.metadata || {}), response: responseMeta }
397
+
398
+ } catch (error) {
399
+ // Track error
400
+ tracer.addEvent(span.id, 'error', {
401
+ message: error instanceof Error ? error.message : 'Unknown error',
402
+ stack: error instanceof Error ? error.stack : undefined,
403
+ })
404
+
405
+ tracer.setAttribute(span.id, 'error', true)
406
+ throw error
407
+
408
+ } finally {
409
+ // End span and add it to trace
410
+ const completedSpan = tracer.endSpan(span.id)
411
+ if (completedSpan) {
412
+ vision.getTraceStore().addSpan(trace.id, completedSpan)
413
+ }
414
+
415
+ // Complete trace
416
+ const duration = Date.now() - startTime
417
+ vision.completeTrace(trace.id, c.res.status, duration)
418
+
419
+ // Add trace ID to response headers so client can correlate metrics
420
+ c.header('X-Vision-Trace-Id', trace.id)
421
+
422
+ // Emit completion log (if enabled)
423
+ if (logging) {
424
+ const { endpoint } = resolveEndpointTemplate(c.req.method, c.req.path)
425
+ console.info(
426
+ `INF request completed code=${c.res.status} duration=${duration}ms method=${c.req.method} endpoint=${endpoint} traceId=${trace.id}`
427
+ )
428
+ }
429
+ }
430
+ }) // Close visionContext.run()
431
+ }
432
+ }
433
+
434
+
435
+ /**
436
+ * Match route path against pattern (simple glob-like matching)
437
+ * e.g., '/users/*' matches '/users/list', '/users/123'
438
+ */
439
+ function matchPattern(path: string, pattern: string): boolean {
440
+ if (pattern.endsWith('/*')) {
441
+ const prefix = pattern.slice(0, -2)
442
+ return path === prefix || path.startsWith(prefix + '/')
443
+ }
444
+ return path === pattern
445
+ }
446
+
447
+ /**
448
+ * Group routes by services (auto or manual)
449
+ */
450
+ function groupRoutesByServices(
451
+ routes: RouteMetadata[],
452
+ servicesConfig?: ServiceDefinition[]
453
+ ): Record<string, { name: string; description?: string; routes: RouteMetadata[] }> {
454
+ const groups: Record<string, { name: string; description?: string; routes: RouteMetadata[] }> = {}
455
+
456
+ // Manual grouping if config provided
457
+ if (servicesConfig && servicesConfig.length > 0) {
458
+ // Initialize groups from config
459
+ servicesConfig.forEach((svc) => {
460
+ groups[svc.name] = { name: svc.name, description: svc.description, routes: [] }
461
+ })
462
+
463
+ // Uncategorized group
464
+ groups['__uncategorized'] = { name: 'Uncategorized', routes: [] }
465
+
466
+ // Assign routes to services
467
+ routes.forEach((route) => {
468
+ let matched = false
469
+ for (const svc of servicesConfig) {
470
+ if (svc.routes.some((pattern) => matchPattern(route.path, pattern))) {
471
+ groups[svc.name].routes.push(route)
472
+ matched = true
473
+ break
474
+ }
475
+ }
476
+ if (!matched) {
477
+ groups['__uncategorized'].routes.push(route)
478
+ }
479
+ })
480
+
481
+ // Remove empty uncategorized
482
+ if (groups['__uncategorized'].routes.length === 0) {
483
+ delete groups['__uncategorized']
484
+ }
485
+ } else {
486
+ // Auto-grouping: group by first path segment
487
+ // If a resource has any subpaths, all its routes go to that service
488
+
489
+ // First pass: collect all routes by first segment
490
+ const routesBySegment = new Map<string, RouteMetadata[]>()
491
+
492
+ for (const route of routes) {
493
+ const segments = route.path.split('/').filter(Boolean)
494
+ const serviceName = segments.length > 0 ? segments[0] : 'root'
495
+
496
+ if (!routesBySegment.has(serviceName)) {
497
+ routesBySegment.set(serviceName, [])
498
+ }
499
+ routesBySegment.get(serviceName)!.push(route)
500
+ }
501
+
502
+ // Second pass: if a service has any multi-segment routes, keep it as a service
503
+ // Otherwise move single-segment routes to Root
504
+ groups['root'] = { name: 'Root', routes: [] }
505
+
506
+ for (const [serviceName, serviceRoutes] of routesBySegment) {
507
+ const hasMultiSegment = serviceRoutes.some(r => r.path.split('/').filter(Boolean).length > 1)
508
+
509
+ if (hasMultiSegment || serviceName === 'root') {
510
+ // This is a real service with subpaths, or it's root
511
+ const capitalizedName = serviceName === 'root' ? 'Root' : serviceName.charAt(0).toUpperCase() + serviceName.slice(1)
512
+
513
+ if (serviceName === 'root') {
514
+ groups['root'].routes.push(...serviceRoutes)
515
+ } else {
516
+ groups[serviceName] = { name: capitalizedName, routes: serviceRoutes }
517
+ }
518
+ } else {
519
+ // Single-segment route with no siblings → goes to Root
520
+ groups['root'].routes.push(...serviceRoutes)
521
+ }
522
+ }
523
+
524
+ // Remove Root if empty
525
+ if (groups['root'].routes.length === 0) {
526
+ delete groups['root']
527
+ }
528
+ }
529
+
530
+ return groups
531
+ }
532
+
533
+ /**
534
+ * Enable auto-discovery of routes (experimental)
535
+ * Patches Hono app methods to automatically register routes
536
+ *
537
+ * @example
538
+ * ```ts
539
+ * const app = new Hono()
540
+ * app.use('*', visionAdapter())
541
+ * enableAutoDiscovery(app)
542
+ *
543
+ * // Routes defined after this will be auto-discovered
544
+ * app.get('/hello', (c) => c.json({ hello: 'world' }))
545
+ * ```
546
+ */
547
+ export function enableAutoDiscovery(app: any, options?: { services?: ServiceDefinition[] }) {
548
+ patchHonoApp(app, options)
549
+ scheduleRegistration(options)
550
+ }
551
+
552
+ /**
553
+ * Get the Vision instance (for advanced usage)
554
+ */
555
+ export function getVisionInstance(): VisionCore | null {
556
+ return visionInstance
557
+ }
558
+
559
+ // Re-export monkey-patched zValidator that stores schema for Vision introspection
560
+ // Use this instead of @hono/zod-validator to enable automatic schema detection
561
+ export { zValidator } from './zod-validator'
@@ -0,0 +1,27 @@
1
+ import { zValidator as originalZValidator } from '@hono/zod-validator'
2
+ import type { ZodType } from 'zod'
3
+
4
+ /**
5
+ * Monkey-patch zValidator to attach schema for Vision introspection
6
+ */
7
+ export const zValidator = new Proxy(originalZValidator, {
8
+ apply(target, thisArg, args: any[]) {
9
+ // Call original zValidator
10
+ const validator = Reflect.apply(target, thisArg, args)
11
+
12
+ // Attach schema (2nd argument) to the returned middleware handler
13
+ const schema = args[1]
14
+ if (schema && typeof schema === 'object' && '_def' in schema) {
15
+ ;(validator as any).__visionSchema = schema
16
+ }
17
+
18
+ return validator
19
+ }
20
+ })
21
+
22
+ /**
23
+ * Extract schema from validator middleware
24
+ */
25
+ export function extractSchema(validator: any): ZodType | undefined {
26
+ return validator?.__visionSchema
27
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "@repo/typescript-config/base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "lib": ["ES2022"],
7
+ "target": "ES2022",
8
+ "module": "ESNext",
9
+ "moduleResolution": "bundler"
10
+ },
11
+ "include": ["src/**/*"],
12
+ "exclude": ["node_modules", "dist"]
13
+ }