@getvision/server 0.2.7 → 0.3.1-3b055ce-develop

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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # @getvision/server
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 28c86e6: Added support for runtime-specific `start` options. Introduced new `VisionStartOptions` type for better handling of configuration parameters.
8
+ Added support for configurable worker concurrency per handler and default event bus settings
9
+
10
+ ### Patch Changes
11
+
12
+ - 2d4e753: Fix priority for dynamic and static routes
13
+
3
14
  ## 0.2.7
4
15
 
5
16
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getvision/server",
3
- "version": "0.2.7",
3
+ "version": "0.3.1-3b055ce-develop",
4
4
  "type": "module",
5
5
  "description": "Vision Server - Meta-framework with built-in observability, pub/sub, and type-safe APIs",
6
6
  "exports": {
@@ -26,6 +26,7 @@
26
26
  "@repo/eslint-config": "0.0.1",
27
27
  "@repo/typescript-config": "0.0.1",
28
28
  "@types/node": "^20.14.9",
29
+ "@types/bun": "^1.3.5",
29
30
  "typescript": "5.9.3"
30
31
  },
31
32
  "keywords": [
package/src/event-bus.ts CHANGED
@@ -11,6 +11,10 @@ export interface EventBusConfig {
11
11
  port?: number
12
12
  password?: string
13
13
  }
14
+ /**
15
+ * Default BullMQ worker concurrency (per event). Per-handler options override this.
16
+ */
17
+ workerConcurrency?: number
14
18
  // Dev mode - use in-memory (no Redis required)
15
19
  devMode?: boolean
16
20
  }
@@ -72,6 +76,7 @@ export class EventBus {
72
76
  this.config = {
73
77
  devMode: resolvedDevMode,
74
78
  redis: mergedRedis,
79
+ workerConcurrency: config.workerConcurrency,
75
80
  }
76
81
  }
77
82
 
@@ -178,7 +183,14 @@ export class EventBus {
178
183
  */
179
184
  registerHandler<T>(
180
185
  eventName: string,
181
- handler: (data: T) => Promise<void>
186
+ handler: (data: T) => Promise<void>,
187
+ options?: {
188
+ /**
189
+ * Max number of concurrent jobs this handler will process.
190
+ * Defaults to config.workerConcurrency (or 1).
191
+ */
192
+ concurrency?: number
193
+ }
182
194
  ): void {
183
195
  if (this.config.devMode) {
184
196
  // Dev mode - store handlers in memory
@@ -191,6 +203,15 @@ export class EventBus {
191
203
  host: 'localhost',
192
204
  port: 6379,
193
205
  }
206
+ const workerKey = `${eventName}-handler`
207
+
208
+ // Close existing worker if it exists
209
+ const existingWorker = this.workers.get(workerKey)
210
+ if (existingWorker) {
211
+ void existingWorker.close().catch(() => {})
212
+ this.workers.delete(workerKey)
213
+ }
214
+
194
215
  const worker = new Worker(
195
216
  eventName,
196
217
  async (job) => {
@@ -203,10 +224,11 @@ export class EventBus {
203
224
  },
204
225
  {
205
226
  connection,
227
+ concurrency: options?.concurrency ?? this.config.workerConcurrency ?? 1,
206
228
  }
207
229
  )
208
230
 
209
- this.workers.set(`${eventName}-${Date.now()}`, worker)
231
+ this.workers.set(workerKey, worker)
210
232
 
211
233
  // Listen to queue events
212
234
  if (!this.queueEvents.has(eventName)) {
@@ -253,6 +275,15 @@ export class EventBus {
253
275
  host: 'localhost',
254
276
  port: 6379,
255
277
  }
278
+ const cronWorkerKey = `${cronName}-handler`
279
+
280
+ // Close existing cron worker if it exists
281
+ const existingCronWorker = this.workers.get(cronWorkerKey)
282
+ if (existingCronWorker) {
283
+ void existingCronWorker.close().catch(() => {})
284
+ this.workers.delete(cronWorkerKey)
285
+ }
286
+
256
287
  const worker = new Worker(
257
288
  cronName,
258
289
  async (job) => {
@@ -274,7 +305,7 @@ export class EventBus {
274
305
  }
275
306
  )
276
307
 
277
- this.workers.set(`${cronName}-${Date.now()}`, worker)
308
+ this.workers.set(cronWorkerKey, worker)
278
309
 
279
310
  // Listen to cron job events
280
311
  if (!this.queueEvents.has(cronName)) {
package/src/router.ts CHANGED
@@ -27,6 +27,10 @@ export async function loadSubApps(app: Hono, routesDir: string = './app/routes',
27
27
  return '/' + segments.join('/')
28
28
  }
29
29
 
30
+ function isDynamicSegment(name: string): boolean {
31
+ return name.startsWith('[') && name.endsWith(']')
32
+ }
33
+
30
34
  async function scan(dir: string) {
31
35
  const entries = readdirSync(dir)
32
36
  // If folder contains index.ts or index.js, treat it as a sub-app root
@@ -73,7 +77,16 @@ export async function loadSubApps(app: Hono, routesDir: string = './app/routes',
73
77
  }
74
78
  }
75
79
  // Recurse into child directories
76
- for (const name of entries) {
80
+ // Sort entries: static folders first, then dynamic [param] folders
81
+ // This ensures static routes have priority over dynamic routes
82
+ const sortedEntries = [...entries].sort((a, b) => {
83
+ const aIsDynamic = isDynamicSegment(a)
84
+ const bIsDynamic = isDynamicSegment(b)
85
+ if (aIsDynamic && !bIsDynamic) return 1 // dynamic after static
86
+ if (!aIsDynamic && bIsDynamic) return -1 // static before dynamic
87
+ return a.localeCompare(b) // alphabetical within same type
88
+ })
89
+ for (const name of sortedEntries) {
77
90
  const full = join(dir, name)
78
91
  const st = statSync(full)
79
92
  if (st.isDirectory()) await scan(full)
package/src/service.ts CHANGED
@@ -240,6 +240,11 @@ export class ServiceBuilder<
240
240
  description?: string
241
241
  icon?: string
242
242
  tags?: string[]
243
+ /**
244
+ * Max number of concurrent jobs this handler will process.
245
+ * Falls back to EventBus config.workerConcurrency (or 1).
246
+ */
247
+ concurrency?: number
243
248
  handler: (event: T, c: Context<E, any, I>) => Promise<void>
244
249
  }
245
250
  ): ServiceBuilder<TEvents & { [key in K]: T }, E, I> {
@@ -268,7 +273,9 @@ export class ServiceBuilder<
268
273
  )
269
274
 
270
275
  // Register wrapped handler in event bus
271
- this.eventBus.registerHandler(eventName, wrappedHandler)
276
+ this.eventBus.registerHandler(eventName, wrappedHandler, {
277
+ concurrency: config.concurrency,
278
+ })
272
279
 
273
280
  // Store for later reference
274
281
  this.eventHandlers.set(eventName, config)
package/src/vision-app.ts CHANGED
@@ -8,6 +8,7 @@ import { spawn, spawnSync, type ChildProcess } from 'child_process'
8
8
  import { ServiceBuilder } from './service'
9
9
  import { EventBus } from './event-bus'
10
10
  import { eventRegistry } from './event-registry'
11
+ import type { serve as honoServe } from '@hono/node-server'
11
12
 
12
13
  export interface VisionALSContext {
13
14
  vision: VisionCore
@@ -17,6 +18,12 @@ export interface VisionALSContext {
17
18
 
18
19
  const visionContext = new AsyncLocalStorage<VisionALSContext>()
19
20
 
21
+ type BunServeOptions = Parameters<typeof Bun['serve']>[0]
22
+ type NodeServeOptions = Parameters<typeof honoServe>[0]
23
+
24
+ type VisionStartOptions = Omit<Partial<BunServeOptions>, 'fetch' | 'port'> &
25
+ Omit<Partial<NodeServeOptions>, 'fetch' | 'port'>
26
+
20
27
  // Simple deep merge utility (objects only, arrays are overwritten by source)
21
28
  function deepMerge<T extends Record<string, any>>(target: T, source: Partial<T>): T {
22
29
  const output: any = { ...target }
@@ -75,6 +82,10 @@ export interface VisionConfig {
75
82
  }
76
83
  devMode?: boolean // Use in-memory event bus (no Redis required)
77
84
  eventBus?: EventBus // Share EventBus instance across apps (for sub-apps)
85
+ /**
86
+ * Default BullMQ worker concurrency for all handlers (overridable per handler)
87
+ */
88
+ workerConcurrency?: number
78
89
  }
79
90
  }
80
91
 
@@ -203,6 +214,7 @@ export class Vision<
203
214
  this.eventBus = this.config.pubsub?.eventBus || new EventBus({
204
215
  redis: this.config.pubsub?.redis,
205
216
  devMode: this.config.pubsub?.devMode,
217
+ workerConcurrency: this.config.pubsub?.workerConcurrency,
206
218
  })
207
219
 
208
220
  // Register JSON-RPC methods for events/cron
@@ -567,7 +579,11 @@ export class Vision<
567
579
  /**
568
580
  * Start the server (convenience method)
569
581
  */
570
- async start(port: number = 3000, options?: { hostname?: string }) {
582
+ async start(port: number = 3000, options?: VisionStartOptions) {
583
+ const { hostname, ...restOptions } = options || {}
584
+ const { fetch: _bf, port: _bp, ...bunRest } = restOptions as Partial<BunServeOptions>
585
+ const { fetch: _nf, port: _np, ...nodeRest } = restOptions as Partial<NodeServeOptions>
586
+
571
587
  // Build all services WITHOUT registering to VisionCore yet
572
588
  const rootSummaries = this.buildAllServices()
573
589
  // Autoload file-based Vision/Hono sub-apps if enabled (returns merged sub-app summaries)
@@ -608,6 +624,10 @@ export class Vision<
608
624
  if (this.shuttingDown) return
609
625
  this.shuttingDown = true
610
626
  console.log('🛑 Shutting down...')
627
+ try {
628
+ // Clear AsyncLocalStorage to prevent memory leaks
629
+ visionContext.disable()
630
+ } catch {}
611
631
  try {
612
632
  if (this.bunServer && typeof this.bunServer.stop === 'function') {
613
633
  try { this.bunServer.stop() } catch {}
@@ -616,6 +636,7 @@ export class Vision<
616
636
  } catch {}
617
637
  try { stopDrizzleStudio() } catch {}
618
638
  try { await this.eventBus.close() } catch {}
639
+ try { eventRegistry.clear() } catch {}
619
640
  }
620
641
 
621
642
  const wrappedCleanup = async () => {
@@ -625,6 +646,17 @@ export class Vision<
625
646
  process.once('SIGINT', wrappedCleanup)
626
647
  process.once('SIGTERM', wrappedCleanup)
627
648
  try { process.once('SIGQUIT', wrappedCleanup) } catch {}
649
+
650
+ // Bun hot-reload: ensure resources are released between reloads.
651
+ // Note: dispose must NOT call process.exit().
652
+ try {
653
+ const hot = (import.meta as any)?.hot
654
+ if (hot && typeof hot.dispose === 'function') {
655
+ hot.dispose(() => {
656
+ void cleanup()
657
+ })
658
+ }
659
+ } catch {}
628
660
 
629
661
  // Prefer Bun if available, then Node.js; otherwise instruct the user to serve manually
630
662
  if (typeof process !== 'undefined' && process.versions?.bun) {
@@ -638,9 +670,10 @@ export class Vision<
638
670
  }
639
671
  } catch {}
640
672
  this.bunServer = BunServe({
673
+ ...bunRest,
641
674
  fetch: this.fetch.bind(this),
642
675
  port,
643
- hostname: options?.hostname
676
+ hostname
644
677
  })
645
678
  try { (globalThis as any).__vision_bun_server = this.bunServer } catch {}
646
679
  } else {
@@ -651,9 +684,10 @@ export class Vision<
651
684
  const { serve } = await import('@hono/node-server')
652
685
  console.log(`Node.js detected`)
653
686
  serve({
687
+ ...nodeRest,
654
688
  fetch: this.fetch.bind(this),
655
689
  port,
656
- hostname: options?.hostname
690
+ hostname
657
691
  })
658
692
  } else {
659
693
  // For other runtimes, just return the app
@@ -756,6 +790,8 @@ function startDrizzleStudio(port: number): boolean {
756
790
  */
757
791
  function stopDrizzleStudio(): void {
758
792
  if (drizzleStudioProcess) {
793
+ // Remove all event listeners to prevent memory leaks
794
+ drizzleStudioProcess.removeAllListeners()
759
795
  drizzleStudioProcess.kill()
760
796
  drizzleStudioProcess = null
761
797
  console.log('🛑 Drizzle Studio stopped')