@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/event-bus.ts
ADDED
|
@@ -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
|
+
}
|