@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 +7 -0
- package/.turbo/turbo-build.log +1 -0
- package/README.md +108 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +491 -0
- package/dist/zod-validator.d.ts +15 -0
- package/dist/zod-validator.d.ts.map +1 -0
- package/dist/zod-validator.js +23 -0
- package/package.json +30 -0
- package/src/index.ts +561 -0
- package/src/zod-validator.ts +27 -0
- package/tsconfig.json +13 -0
package/.eslintrc.cjs
ADDED
|
@@ -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.
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|