@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/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