@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.
@@ -0,0 +1,286 @@
1
+ import { Queue, Worker, QueueEvents } from 'bullmq'
2
+ import type { z, ZodError } from 'zod'
3
+ import { eventRegistry } from './event-registry'
4
+
5
+ /**
6
+ * EventBus configuration
7
+ */
8
+ export interface EventBusConfig {
9
+ redis?: {
10
+ host?: string
11
+ port?: number
12
+ password?: string
13
+ }
14
+ // Dev mode - use in-memory (no Redis required)
15
+ devMode?: boolean
16
+ }
17
+
18
+ /**
19
+ * EventBus - Abstraction over BullMQ
20
+ */
21
+ export class EventBus {
22
+ private queues = new Map<string, Queue>()
23
+ private workers = new Map<string, Worker>()
24
+ private queueEvents = new Map<string, QueueEvents>()
25
+ private config: EventBusConfig
26
+ private devModeHandlers = new Map<string, Array<(data: any) => Promise<void>>>()
27
+
28
+ constructor(config: EventBusConfig = {}) {
29
+ this.config = {
30
+ devMode: config.devMode ?? process.env.NODE_ENV === 'development',
31
+ redis: config.redis ?? {
32
+ host: process.env.REDIS_HOST || 'localhost',
33
+ port: parseInt(process.env.REDIS_PORT || '6379'),
34
+ },
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Get or create a queue for an event
40
+ */
41
+ private getQueue(eventName: string): Queue {
42
+ if (this.config.devMode) {
43
+ // In dev mode, we don't use actual queues
44
+ return null as any
45
+ }
46
+
47
+ let queue = this.queues.get(eventName)
48
+ if (!queue) {
49
+ const connection = this.config.redis || {
50
+ host: 'localhost',
51
+ port: 6379,
52
+ }
53
+ queue = new Queue(eventName, {
54
+ connection,
55
+ })
56
+ this.queues.set(eventName, queue)
57
+ }
58
+ return queue
59
+ }
60
+
61
+ /**
62
+ * Get or create a queue for a cron job
63
+ */
64
+ async getQueueForCron(cronName: string): Promise<Queue> {
65
+ if (this.config.devMode) {
66
+ // In dev mode, return a mock queue
67
+ return {
68
+ upsertJobScheduler: async () => {},
69
+ } as any
70
+ }
71
+
72
+ return this.getQueue(cronName)
73
+ }
74
+
75
+ /**
76
+ * Emit an event
77
+ */
78
+ async emit<T extends Record<string, any>>(
79
+ eventName: string,
80
+ data: T
81
+ ): Promise<void> {
82
+ // Get event metadata from registry
83
+ const eventMeta = eventRegistry.getEvent(eventName)
84
+ if (!eventMeta) {
85
+ throw new Error(`Event "${eventName}" not registered. Did you forget to add .on('${eventName}', {...})?`)
86
+ }
87
+
88
+ // Validate data with Zod schema (enforce no unknown keys when possible)
89
+ try {
90
+ // If the event schema is a ZodObject, use strict() to disallow unknown keys
91
+ const maybeStrictSchema: any = (eventMeta.schema as any)
92
+ const strictSchema = typeof maybeStrictSchema?.strict === 'function'
93
+ ? maybeStrictSchema.strict()
94
+ : eventMeta.schema
95
+
96
+ const validatedData = (strictSchema as typeof eventMeta.schema).parse(data)
97
+
98
+ if (this.config.devMode) {
99
+ // Dev mode - execute handlers immediately (in-memory)
100
+ const handlers = this.devModeHandlers.get(eventName) || []
101
+
102
+ for (const handler of handlers) {
103
+ try {
104
+ await handler(validatedData)
105
+ eventRegistry.incrementEventCount(eventName, false)
106
+ } catch (error) {
107
+ console.error(`Error in handler for event "${eventName}":`, error)
108
+ eventRegistry.incrementEventCount(eventName, true)
109
+ throw error
110
+ }
111
+ }
112
+ } else {
113
+ // Production mode - use BullMQ
114
+ const queue = this.getQueue(eventName)
115
+ await queue.add(eventName, validatedData, {
116
+ attempts: 3,
117
+ backoff: {
118
+ type: 'exponential',
119
+ delay: 2000,
120
+ },
121
+ })
122
+ eventRegistry.incrementEventCount(eventName, false)
123
+ }
124
+ } catch (error) {
125
+ if (error instanceof Error && error.name === 'ZodError') {
126
+ const zodError = error as any
127
+ const errorMessages = zodError.errors?.map((e: any) => ` - ${e.path.join('.')}: ${e.message}`).join('\n') || error.message
128
+ throw new Error(
129
+ `Invalid data for event "${eventName}":\n${errorMessages}`
130
+ )
131
+ }
132
+ throw error
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Register event handler
138
+ */
139
+ registerHandler<T>(
140
+ eventName: string,
141
+ handler: (data: T) => Promise<void>
142
+ ): void {
143
+ if (this.config.devMode) {
144
+ // Dev mode - store handlers in memory
145
+ const handlers = this.devModeHandlers.get(eventName) || []
146
+ handlers.push(handler)
147
+ this.devModeHandlers.set(eventName, handlers)
148
+ } else {
149
+ // Production mode - create BullMQ worker
150
+ const connection = this.config.redis || {
151
+ host: 'localhost',
152
+ port: 6379,
153
+ }
154
+ const worker = new Worker(
155
+ eventName,
156
+ async (job) => {
157
+ try {
158
+ await handler(job.data)
159
+ } catch (error) {
160
+ eventRegistry.incrementEventCount(eventName, true)
161
+ throw error
162
+ }
163
+ },
164
+ {
165
+ connection,
166
+ }
167
+ )
168
+
169
+ this.workers.set(`${eventName}-${Date.now()}`, worker)
170
+
171
+ // Listen to queue events
172
+ if (!this.queueEvents.has(eventName)) {
173
+ const connection = this.config.redis || {
174
+ host: 'localhost',
175
+ port: 6379,
176
+ }
177
+ const queueEvents = new QueueEvents(eventName, {
178
+ connection,
179
+ })
180
+
181
+ queueEvents.on('completed', ({ jobId }) => {
182
+ console.log(`✅ Event "${eventName}" completed (job: ${jobId})`)
183
+ })
184
+
185
+ queueEvents.on('failed', ({ jobId, failedReason }) => {
186
+ console.error(`❌ Event "${eventName}" failed (job: ${jobId}):`, failedReason)
187
+ })
188
+
189
+ this.queueEvents.set(eventName, queueEvents)
190
+ }
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Register cron job handler
196
+ */
197
+ registerCronHandler(
198
+ cronName: string,
199
+ handler: (context: any) => Promise<void>
200
+ ): void {
201
+ if (this.config.devMode) {
202
+ // Dev mode - cron jobs run immediately for testing
203
+ // We'll implement a simple interval-based scheduler
204
+ console.log(`🧹 Cron job "${cronName}" registered (dev mode - manual trigger only)`)
205
+
206
+ // Store handler for manual trigger
207
+ const handlers = this.devModeHandlers.get(cronName) || []
208
+ handlers.push(handler)
209
+ this.devModeHandlers.set(cronName, handlers)
210
+ } else {
211
+ // Production mode - create BullMQ worker for cron jobs
212
+ const connection = this.config.redis || {
213
+ host: 'localhost',
214
+ port: 6379,
215
+ }
216
+ const worker = new Worker(
217
+ cronName,
218
+ async (job) => {
219
+ try {
220
+ // Create a simple context for cron handler
221
+ const context = {
222
+ jobId: job.id,
223
+ timestamp: Date.now(),
224
+ }
225
+ await handler(context)
226
+ eventRegistry.incrementCronCount(cronName, false)
227
+ } catch (error) {
228
+ eventRegistry.incrementCronCount(cronName, true)
229
+ throw error
230
+ }
231
+ },
232
+ {
233
+ connection,
234
+ }
235
+ )
236
+
237
+ this.workers.set(`${cronName}-${Date.now()}`, worker)
238
+
239
+ // Listen to cron job events
240
+ if (!this.queueEvents.has(cronName)) {
241
+ const queueEvents = new QueueEvents(cronName, {
242
+ connection,
243
+ })
244
+
245
+ queueEvents.on('completed', ({ jobId }) => {
246
+ console.log(`✅ Cron job "${cronName}" completed (job: ${jobId})`)
247
+ })
248
+
249
+ queueEvents.on('failed', ({ jobId, failedReason }) => {
250
+ console.error(`❌ Cron job "${cronName}" failed (job: ${jobId}):`, failedReason)
251
+ })
252
+
253
+ this.queueEvents.set(cronName, queueEvents)
254
+ }
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Close all connections
260
+ */
261
+ async close(): Promise<void> {
262
+ if (this.config.devMode) {
263
+ this.devModeHandlers.clear()
264
+ return
265
+ }
266
+
267
+ // Close all queues
268
+ for (const queue of this.queues.values()) {
269
+ await queue.close()
270
+ }
271
+
272
+ // Close all workers
273
+ for (const worker of this.workers.values()) {
274
+ await worker.close()
275
+ }
276
+
277
+ // Close all queue events
278
+ for (const queueEvent of this.queueEvents.values()) {
279
+ await queueEvent.close()
280
+ }
281
+
282
+ this.queues.clear()
283
+ this.workers.clear()
284
+ this.queueEvents.clear()
285
+ }
286
+ }
@@ -0,0 +1,158 @@
1
+ import type { z } from 'zod'
2
+
3
+ /**
4
+ * Event metadata for UI and runtime
5
+ */
6
+ export interface EventMetadata<T = any> {
7
+ name: string
8
+ description?: string
9
+ icon?: string
10
+ tags?: string[]
11
+ schema: z.ZodSchema<T>
12
+ handlers: Array<(data: T) => Promise<void>>
13
+ lastTriggered?: Date
14
+ totalCount: number
15
+ failedCount: number
16
+ }
17
+
18
+ /**
19
+ * Cron job metadata
20
+ */
21
+ export interface CronMetadata {
22
+ name: string
23
+ schedule: string
24
+ description?: string
25
+ icon?: string
26
+ tags?: string[]
27
+ handler: (context: any) => Promise<void>
28
+ lastRun?: Date
29
+ nextRun?: Date
30
+ totalRuns: number
31
+ failedRuns: number
32
+ }
33
+
34
+ /**
35
+ * Global event registry
36
+ */
37
+ export class EventRegistry {
38
+ private events = new Map<string, EventMetadata>()
39
+ private crons = new Map<string, CronMetadata>()
40
+
41
+ /**
42
+ * Register an event with schema and handler
43
+ */
44
+ registerEvent<T>(
45
+ name: string,
46
+ schema: z.ZodSchema<T>,
47
+ handler: (data: T) => Promise<void>,
48
+ metadata?: {
49
+ description?: string
50
+ icon?: string
51
+ tags?: string[]
52
+ }
53
+ ): void {
54
+ const existing = this.events.get(name)
55
+
56
+ if (existing) {
57
+ // Add handler to existing event
58
+ existing.handlers.push(handler)
59
+ } else {
60
+ // Create new event
61
+ this.events.set(name, {
62
+ name,
63
+ schema,
64
+ handlers: [handler],
65
+ description: metadata?.description,
66
+ icon: metadata?.icon,
67
+ tags: metadata?.tags,
68
+ totalCount: 0,
69
+ failedCount: 0,
70
+ })
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Register a cron job
76
+ */
77
+ registerCron(
78
+ name: string,
79
+ schedule: string,
80
+ handler: (context: any) => Promise<void>,
81
+ metadata?: {
82
+ description?: string
83
+ icon?: string
84
+ tags?: string[]
85
+ }
86
+ ): void {
87
+ this.crons.set(name, {
88
+ name,
89
+ schedule,
90
+ handler,
91
+ description: metadata?.description,
92
+ icon: metadata?.icon,
93
+ tags: metadata?.tags,
94
+ totalRuns: 0,
95
+ failedRuns: 0,
96
+ })
97
+ }
98
+
99
+ /**
100
+ * Get event metadata
101
+ */
102
+ getEvent(name: string): EventMetadata | undefined {
103
+ return this.events.get(name)
104
+ }
105
+
106
+ /**
107
+ * Get all events
108
+ */
109
+ getAllEvents(): EventMetadata[] {
110
+ return Array.from(this.events.values())
111
+ }
112
+
113
+ /**
114
+ * Get all cron jobs
115
+ */
116
+ getAllCrons(): CronMetadata[] {
117
+ return Array.from(this.crons.values())
118
+ }
119
+
120
+ /**
121
+ * Increment event count
122
+ */
123
+ incrementEventCount(name: string, failed = false): void {
124
+ const event = this.events.get(name)
125
+ if (event) {
126
+ event.totalCount++
127
+ if (failed) {
128
+ event.failedCount++
129
+ }
130
+ event.lastTriggered = new Date()
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Increment cron run count
136
+ */
137
+ incrementCronCount(name: string, failed = false): void {
138
+ const cron = this.crons.get(name)
139
+ if (cron) {
140
+ cron.totalRuns++
141
+ if (failed) {
142
+ cron.failedRuns++
143
+ }
144
+ cron.lastRun = new Date()
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Clear all registrations (for testing)
150
+ */
151
+ clear(): void {
152
+ this.events.clear()
153
+ this.crons.clear()
154
+ }
155
+ }
156
+
157
+ // Global singleton
158
+ export const eventRegistry = new EventRegistry()
package/src/index.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @getvision/server - Meta-framework with built-in observability
3
+ *
4
+ * Features:
5
+ * - Built on Hono (ultra-fast, edge-ready)
6
+ * - Built-in Vision Dashboard (tracing, logging)
7
+ * - Type-safe Zod validation
8
+ * - Pub/Sub & Cron via BullMQ (automatic)
9
+ * - Service builder pattern
10
+ * - c.span() for custom tracing
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { Vision } from '@getvision/server'
15
+ * import { z } from 'zod'
16
+ *
17
+ * const app = new Vision({
18
+ * service: {
19
+ * name: 'My API',
20
+ * version: '1.0.0'
21
+ * },
22
+ * pubsub: {
23
+ * schemas: {
24
+ * 'user/created': {
25
+ * data: z.object({ userId: z.string() })
26
+ * }
27
+ * }
28
+ * }
29
+ * })
30
+ *
31
+ * const userService = app.service('users')
32
+ * .endpoint('GET', '/users/:id', schema, async (data, c) => {
33
+ * // c.span() is built-in!
34
+ * const user = c.span('db.select', { 'db.table': 'users' }, () => {
35
+ * return db.users.findOne(data.id)
36
+ * })
37
+ * return user
38
+ * })
39
+ * .on('user/created', async (event) => {
40
+ * console.log('User created:', event.data)
41
+ * })
42
+ *
43
+ * app.start(3000)
44
+ * ```
45
+ */
46
+
47
+ // Main Vision class
48
+ export { Vision, getVisionContext } from './vision-app'
49
+ export type { VisionConfig } from './vision-app'
50
+
51
+ // Service builder (usually accessed via app.service())
52
+ export { ServiceBuilder } from './service'
53
+
54
+ // Types
55
+ export type {
56
+ EndpointConfig,
57
+ Handler,
58
+ VisionContext,
59
+ ExtendedContext
60
+ } from './types'
61
+
62
+ // Re-export from core for convenience
63
+ export { VisionCore } from '@getvision/core'
64
+ export type * from '@getvision/core'
package/src/router.ts ADDED
@@ -0,0 +1,100 @@
1
+ import type { Hono } from 'hono'
2
+ import { readdirSync, statSync } from 'fs'
3
+ import { join, resolve, relative, sep } from 'path'
4
+ import { pathToFileURL } from 'url'
5
+
6
+ /**
7
+ * Autoload Vision/Hono sub-apps from a directory structure like app/routes/.../index.ts
8
+ * Each folder becomes a base path. Dynamic segments [id] are converted to :id.
9
+ *
10
+ * Examples:
11
+ * - app/routes/users/index.ts -> /users
12
+ * - app/routes/users/[id]/index.ts -> /users/:id
13
+ * - app/routes/index.ts -> /
14
+ */
15
+ export async function loadSubApps(app: Hono, routesDir: string = './app/routes'): Promise<Array<{ name: string; routes: any[] }>> {
16
+ const mounted: Array<{ base: string }> = []
17
+ const allSubAppSummaries: Array<{ name: string; routes: any[] }> = []
18
+
19
+ function toBasePath(dirPath: string): string {
20
+ const rel = relative(resolve(routesDir), resolve(dirPath))
21
+ if (!rel || rel === '' || rel === '.' ) return '/'
22
+ const segments = rel.split(sep).filter(Boolean).map((s) => {
23
+ if (s.startsWith('[') && s.endsWith(']')) return `:${s.slice(1, -1)}`
24
+ return s
25
+ })
26
+ return '/' + segments.join('/')
27
+ }
28
+
29
+ async function scan(dir: string) {
30
+ const entries = readdirSync(dir)
31
+ // If folder contains index.ts or index.js, treat it as a sub-app root
32
+ const hasTs = entries.includes('index.ts')
33
+ const hasJs = entries.includes('index.js')
34
+ if (hasTs || hasJs) {
35
+ const indexFile = resolve(dir, hasTs ? 'index.ts' : 'index.js')
36
+ const modUrl = pathToFileURL(indexFile).href
37
+ const mod: any = await import(modUrl)
38
+ const subApp = mod?.default
39
+ if (subApp) {
40
+ const base = toBasePath(dir)
41
+ // If it's a Vision sub-app, build its services before mounting
42
+ try {
43
+ if (typeof (subApp as any)?.service === 'function') {
44
+ await (subApp as any).buildAllServices?.()
45
+ // Collect sub-app services/routes for bulk registration later
46
+ const summaries = (subApp as any).getServiceSummaries?.()
47
+ if (Array.isArray(summaries) && summaries.length > 0) {
48
+ // Prefix all route paths with the base path
49
+ const prefixedSummaries = summaries.map(s => ({
50
+ ...s,
51
+ routes: s.routes.map((r: any) => ({
52
+ ...r,
53
+ path: base === '/' ? r.path : base + (r.path === '/' ? '' : r.path)
54
+ }))
55
+ }))
56
+ allSubAppSummaries.push(...prefixedSummaries)
57
+ }
58
+ }
59
+ } catch (e) {
60
+ console.error(`❌ Error preparing sub-app ${dir}:`, (e as any)?.message || e)
61
+ }
62
+ // Mount the sub-app only if it looks like a Hono/Vision instance with routes
63
+ const routes = (subApp as any)?.routes
64
+ if (Array.isArray(routes)) {
65
+ ;(app as any).route(base, subApp)
66
+ mounted.push({ base })
67
+ }
68
+ }
69
+ }
70
+ // Recurse into child directories
71
+ for (const name of entries) {
72
+ const full = join(dir, name)
73
+ const st = statSync(full)
74
+ if (st.isDirectory()) await scan(full)
75
+ }
76
+ }
77
+
78
+ // Only scan if directory exists
79
+ try {
80
+ statSync(routesDir)
81
+ } catch {
82
+ return []
83
+ }
84
+
85
+ await scan(routesDir)
86
+
87
+ // Merge services by name (combine routes from same service name)
88
+ const mergedServices = new Map<string, { name: string; routes: any[] }>()
89
+ for (const summary of allSubAppSummaries) {
90
+ if (mergedServices.has(summary.name)) {
91
+ const existing = mergedServices.get(summary.name)!
92
+ existing.routes.push(...summary.routes)
93
+ } else {
94
+ mergedServices.set(summary.name, { name: summary.name, routes: [...summary.routes] })
95
+ }
96
+ }
97
+
98
+ // Return merged services (don't register here - let caller handle it)
99
+ return Array.from(mergedServices.values())
100
+ }