@getvision/server 0.0.0-develop-20251031183955
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +10 -0
- package/.eslintrc.cjs +7 -0
- package/.turbo/turbo-build.log +1 -0
- package/README.md +542 -0
- package/package.json +42 -0
- package/src/event-bus.ts +286 -0
- package/src/event-registry.ts +158 -0
- package/src/index.ts +64 -0
- package/src/router.ts +100 -0
- package/src/service.ts +412 -0
- package/src/types.ts +74 -0
- package/src/vision-app.ts +685 -0
- package/src/vision.ts +319 -0
- package/tsconfig.json +9 -0
package/src/service.ts
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import type { Hono, Context, MiddlewareHandler } from 'hono'
|
|
2
|
+
import type { z } from 'zod'
|
|
3
|
+
import { VisionCore, generateZodTemplate } from '@getvision/core'
|
|
4
|
+
import type { EndpointConfig, Handler, VisionContext } from './types'
|
|
5
|
+
import { getVisionContext } from './vision-app'
|
|
6
|
+
import { eventRegistry } from './event-registry'
|
|
7
|
+
import type { EventBus } from './event-bus'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Event schema map - accumulates event types as they're registered
|
|
11
|
+
*/
|
|
12
|
+
type EventSchemaMap = Record<string, z.ZodSchema<any>>
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* ServiceBuilder - Builder pattern API for defining services
|
|
16
|
+
*
|
|
17
|
+
* Automatically infers event types from Zod schemas passed to .on()
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* const userService = app.service('users')
|
|
22
|
+
* .use(logger())
|
|
23
|
+
* .endpoint('GET', '/users/:id', { input, output }, handler)
|
|
24
|
+
* .on('user/created', {
|
|
25
|
+
* schema: z.object({ userId: z.string(), email: z.string() }),
|
|
26
|
+
* handler: async (event) => {
|
|
27
|
+
* // event is fully typed: { userId: string, email: string }
|
|
28
|
+
* }
|
|
29
|
+
* })
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export class ServiceBuilder<TEvents extends EventSchemaMap = {}> {
|
|
33
|
+
private endpoints: Map<string, any> = new Map()
|
|
34
|
+
private eventHandlers: Map<string, any> = new Map()
|
|
35
|
+
private cronJobs: Map<string, any> = new Map()
|
|
36
|
+
private globalMiddleware: MiddlewareHandler[] = []
|
|
37
|
+
private eventSchemas: EventSchemaMap = {}
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
private name: string,
|
|
41
|
+
private eventBus: EventBus,
|
|
42
|
+
private visionCore?: VisionCore
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Add global middleware for all endpoints in this service
|
|
47
|
+
*/
|
|
48
|
+
use(...middleware: MiddlewareHandler[]) {
|
|
49
|
+
this.globalMiddleware.push(...middleware)
|
|
50
|
+
return this
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get service name (capitalized) and route metadata without registering
|
|
55
|
+
*/
|
|
56
|
+
public getRoutesMetadata(): Array<{
|
|
57
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
|
58
|
+
path: string
|
|
59
|
+
requestBody?: any
|
|
60
|
+
responseBody?: any
|
|
61
|
+
}> {
|
|
62
|
+
const routes: Array<{
|
|
63
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
|
64
|
+
path: string
|
|
65
|
+
requestBody?: any
|
|
66
|
+
responseBody?: any
|
|
67
|
+
}> = []
|
|
68
|
+
this.endpoints.forEach((ep) => {
|
|
69
|
+
let requestBody = undefined
|
|
70
|
+
if (ep.schema.input && ['POST', 'PUT', 'PATCH'].includes(ep.method)) {
|
|
71
|
+
requestBody = generateZodTemplate(ep.schema.input)
|
|
72
|
+
}
|
|
73
|
+
let responseBody = undefined
|
|
74
|
+
if (ep.schema.output) {
|
|
75
|
+
responseBody = generateZodTemplate(ep.schema.output)
|
|
76
|
+
}
|
|
77
|
+
routes.push({
|
|
78
|
+
method: ep.method,
|
|
79
|
+
path: ep.path,
|
|
80
|
+
requestBody,
|
|
81
|
+
responseBody,
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
return routes
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
public getDisplayName(): string {
|
|
88
|
+
return this.name.charAt(0).toUpperCase() + this.name.slice(1)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Define an HTTP endpoint with Zod validation
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```ts
|
|
96
|
+
* service.endpoint(
|
|
97
|
+
* 'GET',
|
|
98
|
+
* '/users/:id',
|
|
99
|
+
* {
|
|
100
|
+
* input: z.object({ id: z.string() }),
|
|
101
|
+
* output: z.object({ id: z.string(), name: z.string() })
|
|
102
|
+
* },
|
|
103
|
+
* async ({ id }, c) => {
|
|
104
|
+
* return { id, name: 'John' }
|
|
105
|
+
* },
|
|
106
|
+
* { middleware: [authMiddleware] }
|
|
107
|
+
* )
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
endpoint<
|
|
111
|
+
TInputSchema extends z.ZodType,
|
|
112
|
+
TOutputSchema extends z.ZodType
|
|
113
|
+
>(
|
|
114
|
+
method: EndpointConfig['method'],
|
|
115
|
+
path: string,
|
|
116
|
+
schema: {
|
|
117
|
+
input: TInputSchema
|
|
118
|
+
output: TOutputSchema
|
|
119
|
+
},
|
|
120
|
+
handler: Handler<z.infer<TInputSchema>, z.infer<TOutputSchema>, TEvents>,
|
|
121
|
+
config?: Partial<EndpointConfig>
|
|
122
|
+
) {
|
|
123
|
+
this.endpoints.set(`${method}:${path}`, {
|
|
124
|
+
method,
|
|
125
|
+
path,
|
|
126
|
+
handler,
|
|
127
|
+
schema,
|
|
128
|
+
config: { ...config, method, path },
|
|
129
|
+
middleware: config?.middleware || []
|
|
130
|
+
})
|
|
131
|
+
return this
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Subscribe to events with Zod schema validation
|
|
136
|
+
*
|
|
137
|
+
* Automatically infers the event type from the Zod schema.
|
|
138
|
+
* TypeScript will ensure that c.emit() calls match the registered schema.
|
|
139
|
+
*
|
|
140
|
+
* @example
|
|
141
|
+
* ```ts
|
|
142
|
+
* service.on('user/created', {
|
|
143
|
+
* schema: z.object({
|
|
144
|
+
* userId: z.string().uuid(),
|
|
145
|
+
* email: z.string().email()
|
|
146
|
+
* }),
|
|
147
|
+
* description: 'User account created',
|
|
148
|
+
* icon: '👤',
|
|
149
|
+
* tags: ['user', 'auth'],
|
|
150
|
+
* handler: async (event) => {
|
|
151
|
+
* // event is fully typed: { userId: string, email: string }
|
|
152
|
+
* console.log('User created:', event.email)
|
|
153
|
+
* }
|
|
154
|
+
* })
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
on<
|
|
158
|
+
K extends string,
|
|
159
|
+
T extends Record<string, any>
|
|
160
|
+
>(
|
|
161
|
+
eventName: K,
|
|
162
|
+
config: {
|
|
163
|
+
schema: z.ZodSchema<T>
|
|
164
|
+
description?: string
|
|
165
|
+
icon?: string
|
|
166
|
+
tags?: string[]
|
|
167
|
+
handler: (event: T) => Promise<void>
|
|
168
|
+
}
|
|
169
|
+
): ServiceBuilder<TEvents & { [key in K]: T }> {
|
|
170
|
+
const { schema, handler, description, icon, tags } = config
|
|
171
|
+
|
|
172
|
+
// Store schema for type inference
|
|
173
|
+
this.eventSchemas[eventName] = schema
|
|
174
|
+
|
|
175
|
+
// Register in event registry
|
|
176
|
+
eventRegistry.registerEvent(
|
|
177
|
+
eventName,
|
|
178
|
+
schema,
|
|
179
|
+
handler,
|
|
180
|
+
{ description, icon, tags }
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
// Register handler in event bus
|
|
184
|
+
this.eventBus.registerHandler(eventName, handler)
|
|
185
|
+
|
|
186
|
+
// Store for later reference
|
|
187
|
+
this.eventHandlers.set(eventName, config)
|
|
188
|
+
|
|
189
|
+
// Return typed ServiceBuilder with accumulated events
|
|
190
|
+
return this as ServiceBuilder<TEvents & { [key in K]: T }>
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Schedule a cron job using BullMQ Repeatable
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* ```ts
|
|
198
|
+
* service.cron('0 0 * * *', {
|
|
199
|
+
* description: 'Daily cleanup',
|
|
200
|
+
* icon: '🧹',
|
|
201
|
+
* tags: ['maintenance'],
|
|
202
|
+
* handler: async (c) => {
|
|
203
|
+
* console.log('Daily cleanup')
|
|
204
|
+
* }
|
|
205
|
+
* })
|
|
206
|
+
* ```
|
|
207
|
+
*/
|
|
208
|
+
cron(
|
|
209
|
+
schedule: string,
|
|
210
|
+
config: {
|
|
211
|
+
description?: string
|
|
212
|
+
icon?: string
|
|
213
|
+
tags?: string[]
|
|
214
|
+
handler: (context: any) => Promise<void>
|
|
215
|
+
}
|
|
216
|
+
) {
|
|
217
|
+
const { handler, description, icon, tags } = config
|
|
218
|
+
const cronName = `${this.name}.cron.${schedule}`
|
|
219
|
+
|
|
220
|
+
// Register in event registry
|
|
221
|
+
eventRegistry.registerCron(
|
|
222
|
+
cronName,
|
|
223
|
+
schedule,
|
|
224
|
+
handler,
|
|
225
|
+
{ description, icon, tags }
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
// Store for later reference
|
|
229
|
+
this.cronJobs.set(cronName, { schedule, ...config })
|
|
230
|
+
|
|
231
|
+
// Setup BullMQ repeatable job
|
|
232
|
+
// This will be called when the service is built
|
|
233
|
+
this.setupCronJob(cronName, schedule, handler)
|
|
234
|
+
|
|
235
|
+
return this
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Setup BullMQ repeatable job for cron
|
|
240
|
+
*/
|
|
241
|
+
private async setupCronJob(
|
|
242
|
+
cronName: string,
|
|
243
|
+
schedule: string,
|
|
244
|
+
handler: (context: any) => Promise<void>
|
|
245
|
+
) {
|
|
246
|
+
// Get queue from EventBus (we'll add a getQueue method)
|
|
247
|
+
const queue = await this.eventBus.getQueueForCron(cronName)
|
|
248
|
+
|
|
249
|
+
// Register cron job using BullMQ upsertJobScheduler
|
|
250
|
+
await queue.upsertJobScheduler(
|
|
251
|
+
cronName,
|
|
252
|
+
{
|
|
253
|
+
pattern: schedule, // Cron expression (e.g., '0 0 * * *')
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
name: cronName,
|
|
257
|
+
data: {},
|
|
258
|
+
opts: {},
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
// Register worker to process cron jobs
|
|
263
|
+
this.eventBus.registerCronHandler(cronName, handler)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Build and register all endpoints with Hono
|
|
268
|
+
*/
|
|
269
|
+
build(app: Hono, servicesAccumulator?: Array<{ name: string; routes: any[] }>) {
|
|
270
|
+
// Prepare routes with Zod schemas
|
|
271
|
+
const routes = Array.from(this.endpoints.values()).map(ep => {
|
|
272
|
+
// Generate requestBody schema (input)
|
|
273
|
+
let requestBody = undefined
|
|
274
|
+
if (ep.schema.input && ['POST', 'PUT', 'PATCH'].includes(ep.method)) {
|
|
275
|
+
requestBody = generateZodTemplate(ep.schema.input)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Generate responseBody schema (output) - NEW!
|
|
279
|
+
let responseBody = undefined
|
|
280
|
+
if (ep.schema.output) {
|
|
281
|
+
responseBody = generateZodTemplate(ep.schema.output)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
method: ep.method,
|
|
286
|
+
path: ep.path,
|
|
287
|
+
handler: this.name,
|
|
288
|
+
middleware: [],
|
|
289
|
+
requestBody,
|
|
290
|
+
responseBody,
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
const capitalizedName = this.name.charAt(0).toUpperCase() + this.name.slice(1)
|
|
295
|
+
|
|
296
|
+
// Add to accumulator (для батч реєстрації в buildAllServices)
|
|
297
|
+
if (servicesAccumulator) {
|
|
298
|
+
servicesAccumulator.push({
|
|
299
|
+
name: capitalizedName,
|
|
300
|
+
routes
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Register HTTP endpoints
|
|
305
|
+
this.endpoints.forEach((ep) => {
|
|
306
|
+
// Combine global + endpoint-specific middleware
|
|
307
|
+
const allMiddleware = [...this.globalMiddleware, ...ep.middleware]
|
|
308
|
+
|
|
309
|
+
// Create handler with middleware chain
|
|
310
|
+
const finalHandler = async (c: Context) => {
|
|
311
|
+
try {
|
|
312
|
+
// Add span helper and emit to context
|
|
313
|
+
const visionCtx = getVisionContext()
|
|
314
|
+
if (visionCtx && this.visionCore) {
|
|
315
|
+
const { vision, traceId, rootSpanId } = visionCtx
|
|
316
|
+
const tracer = vision.getTracer();
|
|
317
|
+
|
|
318
|
+
// Add span() method to context
|
|
319
|
+
(c as any).span = <T>(
|
|
320
|
+
name: string,
|
|
321
|
+
attributes: Record<string, any> = {},
|
|
322
|
+
fn?: () => T
|
|
323
|
+
): T => {
|
|
324
|
+
const span = tracer.startSpan(name, traceId, rootSpanId)
|
|
325
|
+
|
|
326
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
327
|
+
tracer.setAttribute(span.id, key, value)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const result = fn ? fn() : (undefined as any)
|
|
332
|
+
const completedSpan = tracer.endSpan(span.id)
|
|
333
|
+
|
|
334
|
+
if (completedSpan) {
|
|
335
|
+
vision.getTraceStore().addSpan(traceId, completedSpan)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return result
|
|
339
|
+
} catch (error) {
|
|
340
|
+
tracer.setAttribute(span.id, 'error', true)
|
|
341
|
+
tracer.setAttribute(
|
|
342
|
+
span.id,
|
|
343
|
+
'error.message',
|
|
344
|
+
error instanceof Error ? error.message : String(error)
|
|
345
|
+
)
|
|
346
|
+
const completedSpan = tracer.endSpan(span.id)
|
|
347
|
+
|
|
348
|
+
if (completedSpan) {
|
|
349
|
+
vision.getTraceStore().addSpan(traceId, completedSpan)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
throw error
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Add emit() method to context with type-safe event validation
|
|
357
|
+
(c as any).emit = async <K extends keyof TEvents>(
|
|
358
|
+
eventName: K,
|
|
359
|
+
data: TEvents[K]
|
|
360
|
+
): Promise<void> => {
|
|
361
|
+
return this.eventBus.emit(eventName as string, data)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Parse and merge params, body, query
|
|
366
|
+
const params = c.req.param()
|
|
367
|
+
const query = c.req.query()
|
|
368
|
+
let body = {}
|
|
369
|
+
|
|
370
|
+
if (['POST', 'PUT', 'PATCH'].includes(ep.method)) {
|
|
371
|
+
body = await c.req.json().catch(() => ({}))
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const input = { ...params, ...query, ...body }
|
|
375
|
+
|
|
376
|
+
// Validate input with Zod
|
|
377
|
+
const validated = ep.schema.input.parse(input)
|
|
378
|
+
|
|
379
|
+
// Execute handler
|
|
380
|
+
const result = await ep.handler(validated, c as any)
|
|
381
|
+
|
|
382
|
+
// Validate output with Zod
|
|
383
|
+
const validatedOutput = ep.schema.output.parse(result)
|
|
384
|
+
|
|
385
|
+
return c.json(validatedOutput)
|
|
386
|
+
} catch (error) {
|
|
387
|
+
if ((error as any).name === 'ZodError') {
|
|
388
|
+
return c.json({
|
|
389
|
+
error: 'Validation error',
|
|
390
|
+
details: (error as any).errors
|
|
391
|
+
}, 400)
|
|
392
|
+
}
|
|
393
|
+
throw error
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Register with middleware chain
|
|
398
|
+
if (allMiddleware.length > 0) {
|
|
399
|
+
app.on([ep.method], ep.path, ...allMiddleware, finalHandler)
|
|
400
|
+
} else {
|
|
401
|
+
app.on([ep.method], ep.path, finalHandler)
|
|
402
|
+
}
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
endpoints: Array.from(this.endpoints.values()),
|
|
407
|
+
eventHandlers: Array.from(this.eventHandlers.values()),
|
|
408
|
+
cronJobs: Array.from(this.cronJobs.values())
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Context, MiddlewareHandler } from 'hono'
|
|
2
|
+
import type { z } from 'zod'
|
|
3
|
+
import type { VisionCore } from '@getvision/core'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Vision context stored in AsyncLocalStorage
|
|
7
|
+
*/
|
|
8
|
+
export interface VisionContext {
|
|
9
|
+
vision: VisionCore
|
|
10
|
+
traceId: string
|
|
11
|
+
rootSpanId: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Endpoint configuration options
|
|
16
|
+
*/
|
|
17
|
+
export interface EndpointConfig {
|
|
18
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
|
19
|
+
path: string
|
|
20
|
+
middleware?: MiddlewareHandler[]
|
|
21
|
+
auth?: boolean
|
|
22
|
+
ratelimit?: { requests: number; window: string }
|
|
23
|
+
cache?: { ttl: number }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extended Context with span helper and emit
|
|
28
|
+
*
|
|
29
|
+
* Generic TEvents parameter allows type-safe event emission
|
|
30
|
+
*/
|
|
31
|
+
export interface ExtendedContext<TEvents extends Record<string, any> = {}> extends Context {
|
|
32
|
+
span<T>(
|
|
33
|
+
name: string,
|
|
34
|
+
attributes?: Record<string, any>,
|
|
35
|
+
fn?: () => T
|
|
36
|
+
): T
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Emit an event with type-safe validation
|
|
40
|
+
*
|
|
41
|
+
* The event name and data are validated against registered Zod schemas.
|
|
42
|
+
* TypeScript will ensure:
|
|
43
|
+
* 1. The event name is registered (via .on())
|
|
44
|
+
* 2. The data matches the schema exactly
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* // After .on('user/created', { schema: z.object({ userId: z.string(), email: z.string() }) })
|
|
49
|
+
*
|
|
50
|
+
* // ✅ TypeScript allows this:
|
|
51
|
+
* await c.emit('user/created', {
|
|
52
|
+
* userId: '123',
|
|
53
|
+
* email: 'user@example.com'
|
|
54
|
+
* })
|
|
55
|
+
*
|
|
56
|
+
* // ❌ TypeScript errors:
|
|
57
|
+
* await c.emit('unknown/event', {}) // Event not registered
|
|
58
|
+
* await c.emit('user/created', { userId: '123' }) // Missing email
|
|
59
|
+
* await c.emit('user/created', { userId: '123', email: 'x', extra: 'extra' }) // Extra field
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
emit<K extends keyof TEvents>(
|
|
63
|
+
eventName: K,
|
|
64
|
+
data: TEvents[K]
|
|
65
|
+
): Promise<void>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Handler type with Zod-validated input and Vision-enhanced context
|
|
70
|
+
*/
|
|
71
|
+
export type Handler<TInput = any, TOutput = any, TEvents extends Record<string, any> = {}> = (
|
|
72
|
+
req: TInput,
|
|
73
|
+
ctx: ExtendedContext<TEvents>
|
|
74
|
+
) => Promise<TOutput> | TOutput
|