jax-hono 1.0.19 → 1.0.21

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.
@@ -1,902 +1,902 @@
1
- import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
2
- import { createRequire } from 'node:module'
3
- import { cwd } from 'node:process'
4
- import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'node:path'
5
- import { ensureGeneratedDir, generatedDir, packageDir, projectDir } from './generated-path'
6
-
7
- const srcDir = join(cwd(), 'src')
8
- const projectRequire = createRequire(join(projectDir, 'package.json'))
9
- const moduleExtensions = new Set(['.ts', '.js', '.mjs', '.cjs'])
10
- const generatedHeader = `// This file is generated by jax-hono/build/generate-registry.ts.
11
- // Do not edit it directly.
12
- `
13
-
14
- type ControllerGroup = {
15
- name: string
16
- dirs: string[]
17
- suffix?: string
18
- }
19
-
20
- type PluginConfigItem =
21
- | false
22
- | {
23
- enable?: boolean
24
- package?: string
25
- options?: unknown
26
- }
27
-
28
- type PluginConfig = Record<string, PluginConfigItem>
29
-
30
- type EnabledPlugin = {
31
- name: string
32
- packageName: string
33
- rootDir: string
34
- }
35
-
36
- const controllerGroups: ControllerGroup[] = [
37
- { name: 'api', dirs: ['api', 'controllers/api', 'controller/api', 'modules'], suffix: '.api' },
38
- { name: 'backend', dirs: ['backend', 'controllers/backend', 'controller/backend', 'modules'], suffix: '.backend' },
39
- { name: 'open', dirs: ['open', 'controllers/open', 'controller/open', 'modules'], suffix: '.open' },
40
- { name: 'client', dirs: ['client', 'controllers/client', 'controller/client', 'modules'], suffix: '.client' },
41
- { name: 'controller', dirs: ['controllers', 'controller', 'modules'], suffix: '.controller' },
42
- ]
43
-
44
- const scopedControllerGroups = new Set(
45
- controllerGroups
46
- .filter((group) => group.name !== 'controller')
47
- .map((group) => group.name),
48
- )
49
-
50
- function walkFiles(dir: string): string[] {
51
- if (!existsSync(dir)) {
52
- return []
53
- }
54
-
55
- const files: string[] = []
56
-
57
- for (const item of readdirSync(dir, { withFileTypes: true })) {
58
- const itemPath = join(dir, item.name)
59
-
60
- if (item.isDirectory()) {
61
- files.push(...walkFiles(itemPath))
62
- continue
63
- }
64
-
65
- if (item.isFile() && moduleExtensions.has(parse(item.name).ext)) {
66
- files.push(itemPath)
67
- }
68
- }
69
-
70
- return files
71
- }
72
-
73
- function writeGeneratedFile(fileName: string, content: string) {
74
- const outputFile = join(generatedDir, fileName)
75
-
76
- ensureGeneratedDir()
77
-
78
- if (existsSync(outputFile) && readFileSync(outputFile, 'utf8') === content) {
79
- return
80
- }
81
-
82
- writeFileSync(outputFile, content)
83
- console.log(`Generated ${relative(projectDir, outputFile)}.`)
84
- }
85
-
86
- function toImportPath(filePath: string) {
87
- const path = filePath
88
- .replace(parse(filePath).ext, '')
89
- .replaceAll('\\', '/')
90
-
91
- return path
92
- }
93
-
94
- function toIdentifier(parts: string[]) {
95
- const value = parts
96
- .join('_')
97
- .replace(/[^a-zA-Z0-9_$]/g, '_')
98
- .replace(/^([^a-zA-Z_$])/, '_$1')
99
-
100
- return value || 'module'
101
- }
102
-
103
- function toLowerCamelIdentifier(parts: string[], suffix = '') {
104
- const words = parts
105
- .join('/')
106
- .split(/[^a-zA-Z0-9_$]+/)
107
- .filter(Boolean)
108
- const value = words
109
- .map((word, index) => {
110
- const normalized = toCamelCase(word)
111
-
112
- return index === 0
113
- ? normalized.charAt(0).toLowerCase() + normalized.slice(1)
114
- : toPascalCase(normalized)
115
- })
116
- .join('')
117
-
118
- return toIdentifier([`${value}${suffix}`])
119
- }
120
-
121
- function toCamelCase(value: string) {
122
- return value.replace(/[-_]+([a-zA-Z0-9])/g, (_match, char: string) => char.toUpperCase())
123
- }
124
-
125
- function toPascalCase(value: string) {
126
- const camel = toCamelCase(value)
127
-
128
- return camel.charAt(0).toUpperCase() + camel.slice(1)
129
- }
130
-
131
- function toServiceKey(name: string) {
132
- const baseName = name.endsWith('Service')
133
- ? name.slice(0, -'Service'.length)
134
- : name
135
-
136
- return baseName.replace(/^./, (char) => char.toLowerCase())
137
- }
138
-
139
- function toMiddlewareKey(exportName: string) {
140
- return exportName.endsWith('Middleware')
141
- ? exportName.slice(0, -'Middleware'.length)
142
- : exportName
143
- }
144
-
145
- function stripKnownSuffix(modulePath: string, suffixes: string[]) {
146
- for (const suffix of suffixes) {
147
- if (modulePath.endsWith(suffix)) {
148
- return modulePath.slice(0, -suffix.length)
149
- }
150
- }
151
-
152
- return modulePath
153
- }
154
-
155
- function normalizeModulePath(modulePath: string) {
156
- const parts = modulePath.split('/')
157
- const last = parts.at(-1)
158
- const parent = parts.at(-2)
159
-
160
- if (!last || !parent) {
161
- return modulePath
162
- }
163
-
164
- if (last === parent) {
165
- return parts.slice(0, -1).join('/')
166
- }
167
-
168
- if (last.startsWith(`${parent}-`) || last.startsWith(`${parent}_`)) {
169
- return [...parts.slice(0, -2), last].join('/')
170
- }
171
-
172
- return modulePath
173
- }
174
-
175
- function toModulePath(baseDir: string, filePath: string, suffixes: string[] = []) {
176
- const modulePath = relative(join(srcDir, baseDir), filePath)
177
- .replace(parse(filePath).ext, '')
178
- .replaceAll('\\', '/')
179
- const withoutIndex = modulePath.endsWith('/index')
180
- ? modulePath.slice(0, -'/index'.length)
181
- : modulePath
182
-
183
- const stripped = stripKnownSuffix(withoutIndex, suffixes)
184
-
185
- return baseDir === 'modules' ? normalizeModulePath(stripped) : stripped
186
- }
187
-
188
- function toPluginModulePath(pluginName: string, rootDir: string, filePath: string, suffixes: string[] = []) {
189
- const modulePath = relative(rootDir, filePath)
190
- .replace(parse(filePath).ext, '')
191
- .replaceAll('\\', '/')
192
- const withoutIndex = modulePath.endsWith('/index')
193
- ? modulePath.slice(0, -'/index'.length)
194
- : modulePath
195
- const stripped = stripKnownSuffix(withoutIndex, suffixes)
196
-
197
- return normalizeModulePath(stripped ? `${pluginName}/${stripped}` : pluginName)
198
- }
199
-
200
- function uniqueSorted(values: string[]) {
201
- return [...new Set(values)].sort((left, right) => left.localeCompare(right))
202
- }
203
-
204
- function getFilesFromDirs(dirs: string[]) {
205
- return uniqueSorted(dirs.flatMap((dirName) => walkFiles(join(srcDir, dirName))))
206
- }
207
-
208
- function getEnabledPluginFiles(suffix: string) {
209
- return getEnabledPlugins().flatMap((plugin) => {
210
- return walkFiles(plugin.rootDir)
211
- .filter((filePath) => isSuffixedModuleFile(filePath, suffix))
212
- .map((filePath) => ({ ...plugin, filePath }))
213
- })
214
- }
215
-
216
- function isFileInDir(filePath: string, dirName: string) {
217
- const rel = relative(join(srcDir, dirName), filePath)
218
-
219
- return rel && !rel.startsWith('..')
220
- }
221
-
222
- function isSuffixedModuleFile(filePath: string, suffix: string) {
223
- return relative(srcDir, filePath)
224
- .replace(parse(filePath).ext, '')
225
- .replaceAll('\\', '/')
226
- .endsWith(suffix)
227
- }
228
-
229
- function isModuleFeatureFile(filePath: string, suffix: string) {
230
- return !isFileInDir(filePath, 'modules') || isSuffixedModuleFile(filePath, suffix)
231
- }
232
-
233
- function hasDefaultExport(filePath: string) {
234
- return /\bexport\s+default\b/.test(readFileSync(filePath, 'utf8'))
235
- }
236
-
237
- function indentObject(content: string, spaces = 2) {
238
- if (!content) {
239
- return ''
240
- }
241
-
242
- const indent = ' '.repeat(spaces)
243
-
244
- return content
245
- .split('\n')
246
- .map((line) => `${indent}${line}`)
247
- .join('\n')
248
- }
249
-
250
- function buildNestedObject(entries: { path: string[]; value: string }[]): string {
251
- const groups = new Map<string, { path: string[]; value: string }[]>()
252
- const leaves: { key: string; value: string }[] = []
253
-
254
- for (const entry of entries) {
255
- const [head, ...tail] = entry.path
256
-
257
- if (!head) {
258
- continue
259
- }
260
-
261
- if (tail.length === 0) {
262
- leaves.push({ key: head, value: entry.value })
263
- continue
264
- }
265
-
266
- groups.set(head, [...(groups.get(head) ?? []), { path: tail, value: entry.value }])
267
- }
268
-
269
- const lines = [
270
- ...leaves.map((leaf) => `${toObjectKey(leaf.key)}: ${leaf.value},`),
271
- ...[...groups.entries()].map(([key, groupEntries]) => {
272
- return `${toObjectKey(key)}: {\n${indentObject(buildNestedObject(groupEntries), 2)}\n},`
273
- }),
274
- ]
275
-
276
- return lines.join('\n')
277
- }
278
-
279
- function generateControllersRegistry() {
280
- const used = new Set<string>()
281
- const imports: string[] = []
282
- const controllerExports: string[] = []
283
- const groupLines: string[] = []
284
-
285
- for (const group of controllerGroups) {
286
- const projectEntries = getFilesFromDirs(group.dirs)
287
- .filter((filePath) => basename(filePath) !== 'index.ts')
288
- .filter((filePath) => !group.suffix || isModuleFeatureFile(filePath, group.suffix))
289
- .filter(hasDefaultExport)
290
- .map((filePath, index) => {
291
- const baseDir = group.dirs.find((dirName) => {
292
- return isFileInDir(filePath, dirName)
293
- }) ?? group.dirs[0]
294
- const modulePath = toModulePath(baseDir, filePath, group.suffix ? [group.suffix] : [])
295
- const importName = toIdentifier([group.name, modulePath, String(index), 'Controller'])
296
- const exportName = toLowerCamelIdentifier(group.name === 'controller' ? [modulePath] : [group.name, modulePath], 'Controller')
297
-
298
- return { modulePath, importName, exportName, filePath }
299
- })
300
- const pluginEntries = group.name === 'controller'
301
- ? getEnabledPluginFiles('.controller')
302
- .filter(({ filePath }) => basename(filePath) !== 'index.ts')
303
- .filter(({ filePath }) => hasDefaultExport(filePath))
304
- .map(({ name, rootDir, filePath }, index) => {
305
- const modulePath = toPluginModulePath(name, rootDir, filePath, ['.controller'])
306
- const importName = toIdentifier([group.name, 'plugin', modulePath, String(index), 'Controller'])
307
- const exportName = toLowerCamelIdentifier([modulePath], 'Controller')
308
-
309
- return { modulePath, importName, exportName, filePath }
310
- })
311
- : []
312
- const entries = [...projectEntries, ...pluginEntries]
313
- .filter((entry) => {
314
- if (group.name !== 'controller') {
315
- return true
316
- }
317
-
318
- const [scope] = entry.modulePath.split('/')
319
-
320
- return !scopedControllerGroups.has(scope)
321
- })
322
- .filter((entry) => {
323
- const key = `${group.name}:${entry.modulePath}`
324
-
325
- if (used.has(key)) {
326
- return false
327
- }
328
-
329
- used.add(key)
330
-
331
- return true
332
- })
333
-
334
- imports.push(...entries.map((entry) => `import ${entry.importName} from '${toImportPath(entry.filePath)}'`))
335
- controllerExports.push(...entries.map((entry) => `export const ${entry.exportName} = createController(${entry.importName})`))
336
-
337
- if (entries.length === 0) {
338
- continue
339
- }
340
-
341
- const objectBody = buildNestedObject(entries.map((entry) => ({
342
- path: entry.modulePath.split('/').map(toCamelCase),
343
- value: entry.exportName,
344
- })))
345
-
346
- if (group.name === 'controller') {
347
- groupLines.push(indentObject(objectBody, 2))
348
- } else {
349
- groupLines.push(` ${group.name}: {\n${indentObject(objectBody, 4)}\n },`)
350
- }
351
- }
352
-
353
- const helperImport = imports.length > 0
354
- ? `import { createController } from 'jax-hono/core/controller'\n`
355
- : ''
356
- const content = `${generatedHeader}
357
- ${helperImport}${imports.join('\n')}
358
-
359
- ${controllerExports.join('\n')}
360
-
361
- export const controllers = {
362
- ${groupLines.join('\n')}
363
- }
364
-
365
- declare module 'jax-hono/core/generated' {
366
- interface JaxGenerated {
367
- controllers: typeof controllers
368
- }
369
- }
370
- `
371
-
372
- writeGeneratedFile('controllers.generated.ts', content)
373
- }
374
-
375
- function toObjectKey(value: string) {
376
- return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(value) ? value : JSON.stringify(value)
377
- }
378
-
379
- function formatExportBlock(names: string[]) {
380
- if (names.length === 0) {
381
- return ''
382
- }
383
-
384
- return `export {\n${names.map((name) => ` ${name},`).join('\n')}\n}\n`
385
- }
386
-
387
- function createGeneratedContent(sections: string[]) {
388
- return `${generatedHeader}\n${sections
389
- .map((section) => section.trimEnd())
390
- .filter((section) => section.trim())
391
- .join('\n\n')}\n`
392
- }
393
-
394
- function generateMiddlewaresRegistry() {
395
- const files = getFilesFromDirs(['middlewares', 'middleware', 'modules'])
396
- .filter((filePath) => isModuleFeatureFile(filePath, '.middleware'))
397
- const imports: string[] = []
398
- const entries: string[] = []
399
- const exportNames = new Set<string>()
400
-
401
- files.forEach((filePath) => {
402
- const source = readFileSync(filePath, 'utf8')
403
- const middlewareNames = [...source.matchAll(/\bexport\s+(?:const|function)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g)]
404
- .map((match) => match[1])
405
- .sort((left, right) => left.localeCompare(right))
406
-
407
- if (middlewareNames.length === 0) {
408
- return
409
- }
410
-
411
- imports.push(`import { ${middlewareNames.join(', ')} } from '${toImportPath(filePath)}'`)
412
- middlewareNames.forEach((name) => exportNames.add(name))
413
- entries.push(...middlewareNames.map((name) => {
414
- const key = toMiddlewareKey(name)
415
-
416
- return key === name
417
- ? ` ${name},`
418
- : ` ${key}: ${name},`
419
- }))
420
- })
421
-
422
- const exportBlock = formatExportBlock(Array.from(exportNames).sort((left, right) => left.localeCompare(right)))
423
- const content = createGeneratedContent([
424
- imports.join('\n'),
425
- exportBlock,
426
- `export const middlewares = {
427
- ${entries.join('\n')}
428
- }`,
429
- ])
430
-
431
- writeGeneratedFile('middlewares.generated.ts', content)
432
- }
433
-
434
- function generateModelsRegistry() {
435
- const files = getFilesFromDirs(['models', 'model', 'modules'])
436
- .filter((filePath) => isModuleFeatureFile(filePath, '.model'))
437
- const imports: string[] = []
438
- const entries: string[] = []
439
- const exportNames = new Set<string>()
440
-
441
- files.forEach((filePath) => {
442
- const source = readFileSync(filePath, 'utf8')
443
- const exportedModels = [...source.matchAll(/\bexport\s+const\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*create(?:Loose)?Model\s*\(/g)]
444
- .map((match) => match[1])
445
- .sort((left, right) => left.localeCompare(right))
446
-
447
- if (exportedModels.length === 0) {
448
- return
449
- }
450
-
451
- const modelKey = source.match(/\bexport\s+const\s+modelKey\s*=\s*['"]([a-zA-Z_$][a-zA-Z0-9_$]*)['"]/)?.[1]
452
- const fileModelName = toPascalCase(toModulePath(
453
- filePath.includes(`${srcDir}/modules/`) ? 'modules' : basename(parse(filePath).dir),
454
- filePath,
455
- ['.model'],
456
- ))
457
-
458
- imports.push(`import { ${exportedModels.join(', ')} } from '${toImportPath(filePath)}'`)
459
- exportedModels.forEach((name) => exportNames.add(name))
460
- entries.push(...exportedModels.map((name) => {
461
- const key = exportedModels.length === 1 && modelKey
462
- ? modelKey
463
- : name === `${fileModelName}Model`
464
- ? fileModelName
465
- : name
466
-
467
- return key === name
468
- ? ` ${name},`
469
- : ` ${key}: ${name},`
470
- }))
471
- })
472
-
473
- const exportBlock = formatExportBlock(Array.from(exportNames).sort((left, right) => left.localeCompare(right)))
474
-
475
- const content = `${generatedHeader}
476
- ${imports.join('\n')}
477
-
478
- ${exportBlock}
479
- export const models = {
480
- ${entries.join('\n')}
481
- }
482
-
483
- export type Models = typeof models
484
-
485
- declare module 'jax-hono/core/generated' {
486
- interface JaxGenerated {
487
- models: typeof models
488
- }
489
- }
490
- `
491
-
492
- writeGeneratedFile('models.generated.ts', content)
493
- }
494
-
495
- function generateServicesRegistry() {
496
- const files = [
497
- ...getFilesFromDirs(['services', 'service', 'modules'])
498
- .filter((filePath) => isModuleFeatureFile(filePath, '.service'))
499
- .map((filePath) => ({ filePath })),
500
- ...getEnabledPluginFiles('.service'),
501
- ]
502
- const imports: string[] = []
503
- const entries: string[] = []
504
- const exportNames = new Set<string>()
505
-
506
- files.forEach((item) => {
507
- const { filePath } = item
508
- const source = readFileSync(filePath, 'utf8')
509
- const namedServiceClasses = [...source.matchAll(/\bexport\s+class\s+([a-zA-Z_$][a-zA-Z0-9_$]*Service)\b/g)]
510
- .map((match) => match[1])
511
- .sort((left, right) => left.localeCompare(right))
512
- const defaultServiceClass = source.match(/\bexport\s+default\s+class(?:\s+([a-zA-Z_$][a-zA-Z0-9_$]*))?/)
513
- const fallbackServicePath = 'name' in item
514
- ? toPluginModulePath(item.name, item.rootDir, filePath, ['.service'])
515
- : toModulePath('services', filePath)
516
- const defaultServiceName = defaultServiceClass
517
- ? defaultServiceClass[1] ?? `${toPascalCase(fallbackServicePath)}Service`
518
- : ''
519
- const defaultImportName = defaultServiceName
520
- ? toIdentifier([defaultServiceName, 'Default'])
521
- : ''
522
-
523
- if (namedServiceClasses.length === 0 && !defaultServiceName) {
524
- return
525
- }
526
-
527
- if (defaultServiceName) {
528
- imports.push(`import ${defaultImportName} from '${toImportPath(filePath)}'`)
529
- exportNames.add(`${defaultImportName} as ${defaultServiceName}`)
530
- entries.push(` ${toServiceKey(defaultServiceName)}: new ${defaultImportName}(deps),`)
531
- }
532
-
533
- if (namedServiceClasses.length > 0) {
534
- imports.push(`import { ${namedServiceClasses.join(', ')} } from '${toImportPath(filePath)}'`)
535
- namedServiceClasses.forEach((name) => exportNames.add(name))
536
- entries.push(...namedServiceClasses.map((name) => ` ${toServiceKey(name)}: new ${name}(deps),`))
537
- }
538
- })
539
-
540
- const exportBlock = formatExportBlock(Array.from(exportNames).sort((left, right) => left.localeCompare(right)))
541
- const content = `${generatedHeader}
542
- ${imports.join('\n')}
543
-
544
- ${exportBlock}
545
- export function createServices(deps: any) {
546
- return {
547
- ${entries.join('\n')}
548
- }
549
- }
550
-
551
- export type Services = ReturnType<typeof createServices>
552
-
553
- declare module 'jax-hono/core/generated' {
554
- interface JaxGenerated {
555
- services: Services
556
- }
557
- }
558
- `
559
-
560
- writeGeneratedFile('services.generated.ts', content)
561
- }
562
-
563
- function generateCronsRegistry() {
564
- const files = [
565
- ...getFilesFromDirs(['cron', 'crons'])
566
- .filter((filePath) => basename(filePath) !== 'index.ts')
567
- .map((filePath) => ({
568
- modulePath: toModulePath(filePath.includes(`${srcDir}/crons/`) ? 'crons' : 'cron', filePath, ['.cron']),
569
- filePath,
570
- })),
571
- ...getFilesFromDirs(['modules'])
572
- .filter((filePath) => isModuleFeatureFile(filePath, '.cron'))
573
- .map((filePath) => ({
574
- modulePath: toModulePath('modules', filePath, ['.cron']),
575
- filePath,
576
- })),
577
- ...getEnabledPluginFiles('.cron')
578
- .map(({ name, rootDir, filePath }) => ({
579
- modulePath: toPluginModulePath(name, rootDir, filePath, ['.cron']),
580
- filePath,
581
- })),
582
- ]
583
- .filter(({ filePath }) => hasCronExport(filePath))
584
- .filter((item, index, list) => {
585
- return list.findIndex((row) => row.filePath === item.filePath) === index
586
- })
587
- .sort((left, right) => left.modulePath.localeCompare(right.modulePath))
588
-
589
- const imports = files.map((item) => {
590
- return `import * as ${toLowerCamelIdentifier([item.modulePath], 'Cron')} from '${toImportPath(item.filePath)}'`
591
- })
592
- const exportNames = files.map((item) => toLowerCamelIdentifier([item.modulePath], 'Cron'))
593
- const entries = files.map((item) => {
594
- const importName = toLowerCamelIdentifier([item.modulePath], 'Cron')
595
-
596
- return ` { name: ${JSON.stringify(item.modulePath)}, module: ${importName} },`
597
- })
598
- const exportBlock = formatExportBlock(exportNames)
599
- const content = createGeneratedContent([
600
- imports.join('\n'),
601
- exportBlock,
602
- `export const cronModules = [
603
- ${entries.join('\n')}
604
- ] as const`,
605
- ])
606
-
607
- writeGeneratedFile('crons.generated.ts', content)
608
- }
609
-
610
- function hasCronExport(filePath: string) {
611
- const source = readFileSync(filePath, 'utf8')
612
-
613
- return /\bexport\s+default\b/.test(source)
614
- || /\bexport\s+const\s+(?:cron|jobs)\b/.test(source)
615
- }
616
-
617
- function findBalancedObject(source: string, startIndex: number) {
618
- let depth = 0
619
- let quote: string | undefined
620
- let escaped = false
621
- let lineComment = false
622
- let blockComment = false
623
-
624
- for (let index = startIndex; index < source.length; index += 1) {
625
- const char = source[index]
626
- const next = source[index + 1]
627
-
628
- if (lineComment) {
629
- if (char === '\n') {
630
- lineComment = false
631
- }
632
- continue
633
- }
634
-
635
- if (blockComment) {
636
- if (char === '*' && next === '/') {
637
- blockComment = false
638
- index += 1
639
- }
640
- continue
641
- }
642
-
643
- if (quote) {
644
- if (escaped) {
645
- escaped = false
646
- continue
647
- }
648
-
649
- if (char === '\\') {
650
- escaped = true
651
- continue
652
- }
653
-
654
- if (char === quote) {
655
- quote = undefined
656
- }
657
- continue
658
- }
659
-
660
- if (char === '/' && next === '/') {
661
- lineComment = true
662
- index += 1
663
- continue
664
- }
665
-
666
- if (char === '/' && next === '*') {
667
- blockComment = true
668
- index += 1
669
- continue
670
- }
671
-
672
- if (char === '"' || char === '\'' || char === '`') {
673
- quote = char
674
- continue
675
- }
676
-
677
- if (char === '{') {
678
- depth += 1
679
- continue
680
- }
681
-
682
- if (char === '}') {
683
- depth -= 1
684
-
685
- if (depth === 0) {
686
- return source.slice(startIndex, index + 1)
687
- }
688
- }
689
- }
690
-
691
- return undefined
692
- }
693
-
694
- function extractPluginConfigObject(source: string) {
695
- const configCallIndex = source.indexOf('definePluginConfig')
696
- const searchStart = configCallIndex >= 0 ? configCallIndex : source.indexOf('export default')
697
- const objectStart = searchStart >= 0 ? source.indexOf('{', searchStart) : -1
698
-
699
- if (objectStart < 0) {
700
- return undefined
701
- }
702
-
703
- return findBalancedObject(source, objectStart)
704
- }
705
-
706
- function readPluginConfig(filePath: string): PluginConfig {
707
- if (!existsSync(filePath)) {
708
- return {}
709
- }
710
-
711
- const objectSource = extractPluginConfigObject(readFileSync(filePath, 'utf8'))
712
-
713
- if (!objectSource) {
714
- return {}
715
- }
716
-
717
- try {
718
- const value = new Function(`return (${objectSource})`)() as unknown
719
-
720
- return value && typeof value === 'object' && !Array.isArray(value)
721
- ? (value as PluginConfig)
722
- : {}
723
- } catch (error) {
724
- throw new Error(`Invalid plugin config ${relative(srcDir, filePath)}: ${(error as Error).message}`)
725
- }
726
- }
727
-
728
- function normalizePluginPackage(packageName: string, configFile: string) {
729
- if (!packageName.startsWith('./') && !packageName.startsWith('../')) {
730
- return packageName
731
- }
732
-
733
- const normalized = join(configFile, '..', packageName)
734
- .replaceAll('\\', '/')
735
- .replace(/\/index$/, '')
736
-
737
- return normalized
738
- }
739
-
740
- function mergePluginConfig(configs: { filePath: string; config: PluginConfig }[]) {
741
- const merged = new Map<string, Exclude<PluginConfigItem, false>>()
742
-
743
- for (const { filePath, config } of configs) {
744
- for (const [name, item] of Object.entries(config)) {
745
- if (item === false) {
746
- merged.delete(name)
747
- continue
748
- }
749
-
750
- const current = merged.get(name) ?? {}
751
- const next = { ...current, ...item }
752
-
753
- if (next.package) {
754
- next.package = normalizePluginPackage(next.package, filePath)
755
- }
756
-
757
- merged.set(name, next)
758
- }
759
- }
760
-
761
- return [...merged.entries()]
762
- .filter((entry): entry is [string, Exclude<PluginConfigItem, false> & { package: string }] => {
763
- const [, item] = entry
764
-
765
- return item.enable === true && typeof item.package === 'string' && item.package.length > 0
766
- })
767
- .sort(([left], [right]) => left.localeCompare(right))
768
- }
769
-
770
- function getEnabledPluginConfigItems() {
771
- return mergePluginConfig([
772
- { filePath: join(packageDir, 'plugins/config.ts'), config: readPluginConfig(join(packageDir, 'plugins/config.ts')) },
773
- { filePath: join(srcDir, 'config/plugin.ts'), config: readPluginConfig(join(srcDir, 'config/plugin.ts')) },
774
- ])
775
- }
776
-
777
- function resolvePluginPackageDir(packageName: string) {
778
- const resolved = resolvePluginPackage(packageName)
779
-
780
- return statSync(resolved).isDirectory()
781
- ? resolved
782
- : dirname(resolved)
783
- }
784
-
785
- function resolvePluginPackage(packageName: string) {
786
- if (isAbsolute(packageName)) {
787
- return packageName
788
- }
789
-
790
- if (packageName.startsWith('./') || packageName.startsWith('../')) {
791
- return resolve(projectDir, packageName)
792
- }
793
-
794
- if (packageName.startsWith('jax-hono/')) {
795
- const packagePath = packageName.slice('jax-hono/'.length)
796
- const directPath = join(packageDir, packagePath)
797
-
798
- if (existsSync(directPath)) {
799
- return directPath
800
- }
801
-
802
- const indexPath = join(directPath, 'index.ts')
803
-
804
- if (existsSync(indexPath)) {
805
- return indexPath
806
- }
807
- }
808
-
809
- return projectRequire.resolve(packageName)
810
- }
811
-
812
- function getEnabledPlugins(): EnabledPlugin[] {
813
- return getEnabledPluginConfigItems()
814
- .map(([name, item]) => {
815
- const rootDir = resolvePluginPackageDir(item.package)
816
-
817
- return {
818
- name,
819
- packageName: item.package,
820
- rootDir,
821
- }
822
- })
823
- .filter((plugin) => existsSync(plugin.rootDir))
824
- }
825
-
826
- function serializePluginOptions(options: unknown) {
827
- return options === undefined ? '' : `, options: ${JSON.stringify(options)}`
828
- }
829
-
830
- function generatePluginContextRegistry() {
831
- const enabledPlugins = getEnabledPluginConfigItems()
832
- const imports = enabledPlugins.map(([name, item]) => {
833
- return `import ${toLowerCamelIdentifier([name], 'Plugin')} from ${JSON.stringify(item.package)}`
834
- })
835
- const exportNames = enabledPlugins.map(([name]) => toLowerCamelIdentifier([name], 'Plugin'))
836
- const moduleEntries = enabledPlugins.map(
837
- ([name, item]) =>
838
- ` { name: ${JSON.stringify(name)}, module: ${toLowerCamelIdentifier([name], 'Plugin')}${serializePluginOptions(item.options)} },`,
839
- )
840
- const exportBlock = formatExportBlock(exportNames)
841
-
842
- const content = createGeneratedContent([
843
- imports.join('\n'),
844
- exportBlock,
845
- `export const plugins = {} as Record<string, unknown>
846
-
847
- export type Plugins = typeof plugins
848
-
849
- declare module 'jax-hono/core/generated' {
850
- interface JaxGenerated {
851
- plugins: Plugins
852
- }
853
- }
854
-
855
- export const pluginModules = [
856
- ${moduleEntries.join('\n')}
857
- ] as const`,
858
- ])
859
-
860
- writeGeneratedFile('plugins.generated.ts', content)
861
- }
862
-
863
- function generateGeneratedEntry() {
864
- const content = `${generatedHeader}
865
- import * as configModule from './config'
866
- import * as controllersModule from './controllers.generated'
867
- import * as middlewaresModule from './middlewares.generated'
868
- import * as modelsModule from './models.generated'
869
- import * as servicesModule from './services.generated'
870
- import * as cronsModule from './crons.generated'
871
- import * as pluginsModule from './plugins.generated'
872
- import { registerGeneratedModules } from 'jax-hono/core/generated'
873
-
874
- registerGeneratedModules({
875
- 'config.ts': configModule,
876
- 'controllers.generated.ts': controllersModule,
877
- 'middlewares.generated.ts': middlewaresModule,
878
- 'models.generated.ts': modelsModule,
879
- 'services.generated.ts': servicesModule,
880
- 'crons.generated.ts': cronsModule,
881
- 'plugins.generated.ts': pluginsModule,
882
- })
883
-
884
- export * from 'jax-hono'
885
- `
886
-
887
- writeGeneratedFile('index.ts', content)
888
- }
889
-
890
- export function generateRegistry() {
891
- generateControllersRegistry()
892
- generateMiddlewaresRegistry()
893
- generateModelsRegistry()
894
- generateServicesRegistry()
895
- generateCronsRegistry()
896
- generatePluginContextRegistry()
897
- generateGeneratedEntry()
898
- }
899
-
900
- if (import.meta.main) {
901
- generateRegistry()
902
- }
1
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import { cwd } from 'node:process'
4
+ import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'node:path'
5
+ import { ensureGeneratedDir, generatedDir, packageDir, projectDir } from './generated-path'
6
+
7
+ const srcDir = join(cwd(), 'src')
8
+ const projectRequire = createRequire(join(projectDir, 'package.json'))
9
+ const moduleExtensions = new Set(['.ts', '.js', '.mjs', '.cjs'])
10
+ const generatedHeader = `// This file is generated by jax-hono/build/generate-registry.ts.
11
+ // Do not edit it directly.
12
+ `
13
+
14
+ type ControllerGroup = {
15
+ name: string
16
+ dirs: string[]
17
+ suffix?: string
18
+ }
19
+
20
+ type PluginConfigItem =
21
+ | false
22
+ | {
23
+ enable?: boolean
24
+ package?: string
25
+ options?: unknown
26
+ }
27
+
28
+ type PluginConfig = Record<string, PluginConfigItem>
29
+
30
+ type EnabledPlugin = {
31
+ name: string
32
+ packageName: string
33
+ rootDir: string
34
+ }
35
+
36
+ const controllerGroups: ControllerGroup[] = [
37
+ { name: 'api', dirs: ['api', 'controllers/api', 'controller/api', 'modules'], suffix: '.api' },
38
+ { name: 'backend', dirs: ['backend', 'controllers/backend', 'controller/backend', 'modules'], suffix: '.backend' },
39
+ { name: 'open', dirs: ['open', 'controllers/open', 'controller/open', 'modules'], suffix: '.open' },
40
+ { name: 'client', dirs: ['client', 'controllers/client', 'controller/client', 'modules'], suffix: '.client' },
41
+ { name: 'controller', dirs: ['controllers', 'controller', 'modules'], suffix: '.controller' },
42
+ ]
43
+
44
+ const scopedControllerGroups = new Set(
45
+ controllerGroups
46
+ .filter((group) => group.name !== 'controller')
47
+ .map((group) => group.name),
48
+ )
49
+
50
+ function walkFiles(dir: string): string[] {
51
+ if (!existsSync(dir)) {
52
+ return []
53
+ }
54
+
55
+ const files: string[] = []
56
+
57
+ for (const item of readdirSync(dir, { withFileTypes: true })) {
58
+ const itemPath = join(dir, item.name)
59
+
60
+ if (item.isDirectory()) {
61
+ files.push(...walkFiles(itemPath))
62
+ continue
63
+ }
64
+
65
+ if (item.isFile() && moduleExtensions.has(parse(item.name).ext)) {
66
+ files.push(itemPath)
67
+ }
68
+ }
69
+
70
+ return files
71
+ }
72
+
73
+ function writeGeneratedFile(fileName: string, content: string) {
74
+ const outputFile = join(generatedDir, fileName)
75
+
76
+ ensureGeneratedDir()
77
+
78
+ if (existsSync(outputFile) && readFileSync(outputFile, 'utf8') === content) {
79
+ return
80
+ }
81
+
82
+ writeFileSync(outputFile, content)
83
+ console.log(`Generated ${relative(projectDir, outputFile)}.`)
84
+ }
85
+
86
+ function toImportPath(filePath: string) {
87
+ const path = filePath
88
+ .replace(parse(filePath).ext, '')
89
+ .replaceAll('\\', '/')
90
+
91
+ return path
92
+ }
93
+
94
+ function toIdentifier(parts: string[]) {
95
+ const value = parts
96
+ .join('_')
97
+ .replace(/[^a-zA-Z0-9_$]/g, '_')
98
+ .replace(/^([^a-zA-Z_$])/, '_$1')
99
+
100
+ return value || 'module'
101
+ }
102
+
103
+ function toLowerCamelIdentifier(parts: string[], suffix = '') {
104
+ const words = parts
105
+ .join('/')
106
+ .split(/[^a-zA-Z0-9_$]+/)
107
+ .filter(Boolean)
108
+ const value = words
109
+ .map((word, index) => {
110
+ const normalized = toCamelCase(word)
111
+
112
+ return index === 0
113
+ ? normalized.charAt(0).toLowerCase() + normalized.slice(1)
114
+ : toPascalCase(normalized)
115
+ })
116
+ .join('')
117
+
118
+ return toIdentifier([`${value}${suffix}`])
119
+ }
120
+
121
+ function toCamelCase(value: string) {
122
+ return value.replace(/[-_]+([a-zA-Z0-9])/g, (_match, char: string) => char.toUpperCase())
123
+ }
124
+
125
+ function toPascalCase(value: string) {
126
+ const camel = toCamelCase(value)
127
+
128
+ return camel.charAt(0).toUpperCase() + camel.slice(1)
129
+ }
130
+
131
+ function toServiceKey(name: string) {
132
+ const baseName = name.endsWith('Service')
133
+ ? name.slice(0, -'Service'.length)
134
+ : name
135
+
136
+ return baseName.replace(/^./, (char) => char.toLowerCase())
137
+ }
138
+
139
+ function toMiddlewareKey(exportName: string) {
140
+ return exportName.endsWith('Middleware')
141
+ ? exportName.slice(0, -'Middleware'.length)
142
+ : exportName
143
+ }
144
+
145
+ function stripKnownSuffix(modulePath: string, suffixes: string[]) {
146
+ for (const suffix of suffixes) {
147
+ if (modulePath.endsWith(suffix)) {
148
+ return modulePath.slice(0, -suffix.length)
149
+ }
150
+ }
151
+
152
+ return modulePath
153
+ }
154
+
155
+ function normalizeModulePath(modulePath: string) {
156
+ const parts = modulePath.split('/')
157
+ const last = parts.at(-1)
158
+ const parent = parts.at(-2)
159
+
160
+ if (!last || !parent) {
161
+ return modulePath
162
+ }
163
+
164
+ if (last === parent) {
165
+ return parts.slice(0, -1).join('/')
166
+ }
167
+
168
+ if (last.startsWith(`${parent}-`) || last.startsWith(`${parent}_`)) {
169
+ return [...parts.slice(0, -2), last].join('/')
170
+ }
171
+
172
+ return modulePath
173
+ }
174
+
175
+ function toModulePath(baseDir: string, filePath: string, suffixes: string[] = []) {
176
+ const modulePath = relative(join(srcDir, baseDir), filePath)
177
+ .replace(parse(filePath).ext, '')
178
+ .replaceAll('\\', '/')
179
+ const withoutIndex = modulePath.endsWith('/index')
180
+ ? modulePath.slice(0, -'/index'.length)
181
+ : modulePath
182
+
183
+ const stripped = stripKnownSuffix(withoutIndex, suffixes)
184
+
185
+ return baseDir === 'modules' ? normalizeModulePath(stripped) : stripped
186
+ }
187
+
188
+ function toPluginModulePath(pluginName: string, rootDir: string, filePath: string, suffixes: string[] = []) {
189
+ const modulePath = relative(rootDir, filePath)
190
+ .replace(parse(filePath).ext, '')
191
+ .replaceAll('\\', '/')
192
+ const withoutIndex = modulePath.endsWith('/index')
193
+ ? modulePath.slice(0, -'/index'.length)
194
+ : modulePath
195
+ const stripped = stripKnownSuffix(withoutIndex, suffixes)
196
+
197
+ return normalizeModulePath(stripped ? `${pluginName}/${stripped}` : pluginName)
198
+ }
199
+
200
+ function uniqueSorted(values: string[]) {
201
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right))
202
+ }
203
+
204
+ function getFilesFromDirs(dirs: string[]) {
205
+ return uniqueSorted(dirs.flatMap((dirName) => walkFiles(join(srcDir, dirName))))
206
+ }
207
+
208
+ function getEnabledPluginFiles(suffix: string) {
209
+ return getEnabledPlugins().flatMap((plugin) => {
210
+ return walkFiles(plugin.rootDir)
211
+ .filter((filePath) => isSuffixedModuleFile(filePath, suffix))
212
+ .map((filePath) => ({ ...plugin, filePath }))
213
+ })
214
+ }
215
+
216
+ function isFileInDir(filePath: string, dirName: string) {
217
+ const rel = relative(join(srcDir, dirName), filePath)
218
+
219
+ return rel && !rel.startsWith('..')
220
+ }
221
+
222
+ function isSuffixedModuleFile(filePath: string, suffix: string) {
223
+ return relative(srcDir, filePath)
224
+ .replace(parse(filePath).ext, '')
225
+ .replaceAll('\\', '/')
226
+ .endsWith(suffix)
227
+ }
228
+
229
+ function isModuleFeatureFile(filePath: string, suffix: string) {
230
+ return !isFileInDir(filePath, 'modules') || isSuffixedModuleFile(filePath, suffix)
231
+ }
232
+
233
+ function hasDefaultExport(filePath: string) {
234
+ return /\bexport\s+default\b/.test(readFileSync(filePath, 'utf8'))
235
+ }
236
+
237
+ function indentObject(content: string, spaces = 2) {
238
+ if (!content) {
239
+ return ''
240
+ }
241
+
242
+ const indent = ' '.repeat(spaces)
243
+
244
+ return content
245
+ .split('\n')
246
+ .map((line) => `${indent}${line}`)
247
+ .join('\n')
248
+ }
249
+
250
+ function buildNestedObject(entries: { path: string[]; value: string }[]): string {
251
+ const groups = new Map<string, { path: string[]; value: string }[]>()
252
+ const leaves: { key: string; value: string }[] = []
253
+
254
+ for (const entry of entries) {
255
+ const [head, ...tail] = entry.path
256
+
257
+ if (!head) {
258
+ continue
259
+ }
260
+
261
+ if (tail.length === 0) {
262
+ leaves.push({ key: head, value: entry.value })
263
+ continue
264
+ }
265
+
266
+ groups.set(head, [...(groups.get(head) ?? []), { path: tail, value: entry.value }])
267
+ }
268
+
269
+ const lines = [
270
+ ...leaves.map((leaf) => `${toObjectKey(leaf.key)}: ${leaf.value},`),
271
+ ...[...groups.entries()].map(([key, groupEntries]) => {
272
+ return `${toObjectKey(key)}: {\n${indentObject(buildNestedObject(groupEntries), 2)}\n},`
273
+ }),
274
+ ]
275
+
276
+ return lines.join('\n')
277
+ }
278
+
279
+ function generateControllersRegistry() {
280
+ const used = new Set<string>()
281
+ const imports: string[] = []
282
+ const controllerExports: string[] = []
283
+ const groupLines: string[] = []
284
+
285
+ for (const group of controllerGroups) {
286
+ const projectEntries = getFilesFromDirs(group.dirs)
287
+ .filter((filePath) => basename(filePath) !== 'index.ts')
288
+ .filter((filePath) => !group.suffix || isModuleFeatureFile(filePath, group.suffix))
289
+ .filter(hasDefaultExport)
290
+ .map((filePath, index) => {
291
+ const baseDir = group.dirs.find((dirName) => {
292
+ return isFileInDir(filePath, dirName)
293
+ }) ?? group.dirs[0]
294
+ const modulePath = toModulePath(baseDir, filePath, group.suffix ? [group.suffix] : [])
295
+ const importName = toIdentifier([group.name, modulePath, String(index), 'Controller'])
296
+ const exportName = toLowerCamelIdentifier(group.name === 'controller' ? [modulePath] : [group.name, modulePath], 'Controller')
297
+
298
+ return { modulePath, importName, exportName, filePath }
299
+ })
300
+ const pluginEntries = group.name === 'controller'
301
+ ? getEnabledPluginFiles('.controller')
302
+ .filter(({ filePath }) => basename(filePath) !== 'index.ts')
303
+ .filter(({ filePath }) => hasDefaultExport(filePath))
304
+ .map(({ name, rootDir, filePath }, index) => {
305
+ const modulePath = toPluginModulePath(name, rootDir, filePath, ['.controller'])
306
+ const importName = toIdentifier([group.name, 'plugin', modulePath, String(index), 'Controller'])
307
+ const exportName = toLowerCamelIdentifier([modulePath], 'Controller')
308
+
309
+ return { modulePath, importName, exportName, filePath }
310
+ })
311
+ : []
312
+ const entries = [...projectEntries, ...pluginEntries]
313
+ .filter((entry) => {
314
+ if (group.name !== 'controller') {
315
+ return true
316
+ }
317
+
318
+ const [scope] = entry.modulePath.split('/')
319
+
320
+ return !scopedControllerGroups.has(scope)
321
+ })
322
+ .filter((entry) => {
323
+ const key = `${group.name}:${entry.modulePath}`
324
+
325
+ if (used.has(key)) {
326
+ return false
327
+ }
328
+
329
+ used.add(key)
330
+
331
+ return true
332
+ })
333
+
334
+ imports.push(...entries.map((entry) => `import ${entry.importName} from '${toImportPath(entry.filePath)}'`))
335
+ controllerExports.push(...entries.map((entry) => `export const ${entry.exportName} = createController(${entry.importName})`))
336
+
337
+ if (entries.length === 0) {
338
+ continue
339
+ }
340
+
341
+ const objectBody = buildNestedObject(entries.map((entry) => ({
342
+ path: entry.modulePath.split('/').map(toCamelCase),
343
+ value: entry.exportName,
344
+ })))
345
+
346
+ if (group.name === 'controller') {
347
+ groupLines.push(indentObject(objectBody, 2))
348
+ } else {
349
+ groupLines.push(` ${group.name}: {\n${indentObject(objectBody, 4)}\n },`)
350
+ }
351
+ }
352
+
353
+ const helperImport = imports.length > 0
354
+ ? `import { createController } from 'jax-hono/core/controller'\n`
355
+ : ''
356
+ const content = `${generatedHeader}
357
+ ${helperImport}${imports.join('\n')}
358
+
359
+ ${controllerExports.join('\n')}
360
+
361
+ export const controllers = {
362
+ ${groupLines.join('\n')}
363
+ }
364
+
365
+ declare module 'jax-hono/core/generated' {
366
+ interface JaxGenerated {
367
+ controllers: typeof controllers
368
+ }
369
+ }
370
+ `
371
+
372
+ writeGeneratedFile('controllers.generated.ts', content)
373
+ }
374
+
375
+ function toObjectKey(value: string) {
376
+ return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(value) ? value : JSON.stringify(value)
377
+ }
378
+
379
+ function formatExportBlock(names: string[]) {
380
+ if (names.length === 0) {
381
+ return ''
382
+ }
383
+
384
+ return `export {\n${names.map((name) => ` ${name},`).join('\n')}\n}\n`
385
+ }
386
+
387
+ function createGeneratedContent(sections: string[]) {
388
+ return `${generatedHeader}\n${sections
389
+ .map((section) => section.trimEnd())
390
+ .filter((section) => section.trim())
391
+ .join('\n\n')}\n`
392
+ }
393
+
394
+ function generateMiddlewaresRegistry() {
395
+ const files = getFilesFromDirs(['middlewares', 'middleware', 'modules'])
396
+ .filter((filePath) => isModuleFeatureFile(filePath, '.middleware'))
397
+ const imports: string[] = []
398
+ const entries: string[] = []
399
+ const exportNames = new Set<string>()
400
+
401
+ files.forEach((filePath) => {
402
+ const source = readFileSync(filePath, 'utf8')
403
+ const middlewareNames = [...source.matchAll(/\bexport\s+(?:const|function)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g)]
404
+ .map((match) => match[1])
405
+ .sort((left, right) => left.localeCompare(right))
406
+
407
+ if (middlewareNames.length === 0) {
408
+ return
409
+ }
410
+
411
+ imports.push(`import { ${middlewareNames.join(', ')} } from '${toImportPath(filePath)}'`)
412
+ middlewareNames.forEach((name) => exportNames.add(name))
413
+ entries.push(...middlewareNames.map((name) => {
414
+ const key = toMiddlewareKey(name)
415
+
416
+ return key === name
417
+ ? ` ${name},`
418
+ : ` ${key}: ${name},`
419
+ }))
420
+ })
421
+
422
+ const exportBlock = formatExportBlock(Array.from(exportNames).sort((left, right) => left.localeCompare(right)))
423
+ const content = createGeneratedContent([
424
+ imports.join('\n'),
425
+ exportBlock,
426
+ `export const middlewares = {
427
+ ${entries.join('\n')}
428
+ }`,
429
+ ])
430
+
431
+ writeGeneratedFile('middlewares.generated.ts', content)
432
+ }
433
+
434
+ function generateModelsRegistry() {
435
+ const files = getFilesFromDirs(['models', 'model', 'modules'])
436
+ .filter((filePath) => isModuleFeatureFile(filePath, '.model'))
437
+ const imports: string[] = []
438
+ const entries: string[] = []
439
+ const exportNames = new Set<string>()
440
+
441
+ files.forEach((filePath) => {
442
+ const source = readFileSync(filePath, 'utf8')
443
+ const exportedModels = [...source.matchAll(/\bexport\s+const\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*create(?:Loose)?Model\s*\(/g)]
444
+ .map((match) => match[1])
445
+ .sort((left, right) => left.localeCompare(right))
446
+
447
+ if (exportedModels.length === 0) {
448
+ return
449
+ }
450
+
451
+ const modelKey = source.match(/\bexport\s+const\s+modelKey\s*=\s*['"]([a-zA-Z_$][a-zA-Z0-9_$]*)['"]/)?.[1]
452
+ const fileModelName = toPascalCase(toModulePath(
453
+ filePath.includes(`${srcDir}/modules/`) ? 'modules' : basename(parse(filePath).dir),
454
+ filePath,
455
+ ['.model'],
456
+ ))
457
+
458
+ imports.push(`import { ${exportedModels.join(', ')} } from '${toImportPath(filePath)}'`)
459
+ exportedModels.forEach((name) => exportNames.add(name))
460
+ entries.push(...exportedModels.map((name) => {
461
+ const key = exportedModels.length === 1 && modelKey
462
+ ? modelKey
463
+ : name === `${fileModelName}Model`
464
+ ? fileModelName
465
+ : name
466
+
467
+ return key === name
468
+ ? ` ${name},`
469
+ : ` ${key}: ${name},`
470
+ }))
471
+ })
472
+
473
+ const exportBlock = formatExportBlock(Array.from(exportNames).sort((left, right) => left.localeCompare(right)))
474
+
475
+ const content = `${generatedHeader}
476
+ ${imports.join('\n')}
477
+
478
+ ${exportBlock}
479
+ export const models = {
480
+ ${entries.join('\n')}
481
+ }
482
+
483
+ export type Models = typeof models
484
+
485
+ declare module 'jax-hono/core/generated' {
486
+ interface JaxGenerated {
487
+ models: typeof models
488
+ }
489
+ }
490
+ `
491
+
492
+ writeGeneratedFile('models.generated.ts', content)
493
+ }
494
+
495
+ function generateServicesRegistry() {
496
+ const files = [
497
+ ...getFilesFromDirs(['services', 'service', 'modules'])
498
+ .filter((filePath) => isModuleFeatureFile(filePath, '.service'))
499
+ .map((filePath) => ({ filePath })),
500
+ ...getEnabledPluginFiles('.service'),
501
+ ]
502
+ const imports: string[] = []
503
+ const entries: string[] = []
504
+ const exportNames = new Set<string>()
505
+
506
+ files.forEach((item) => {
507
+ const { filePath } = item
508
+ const source = readFileSync(filePath, 'utf8')
509
+ const namedServiceClasses = [...source.matchAll(/\bexport\s+class\s+([a-zA-Z_$][a-zA-Z0-9_$]*Service)\b/g)]
510
+ .map((match) => match[1])
511
+ .sort((left, right) => left.localeCompare(right))
512
+ const defaultServiceClass = source.match(/\bexport\s+default\s+class(?:\s+([a-zA-Z_$][a-zA-Z0-9_$]*))?/)
513
+ const fallbackServicePath = 'name' in item
514
+ ? toPluginModulePath(item.name, item.rootDir, filePath, ['.service'])
515
+ : toModulePath('services', filePath)
516
+ const defaultServiceName = defaultServiceClass
517
+ ? defaultServiceClass[1] ?? `${toPascalCase(fallbackServicePath)}Service`
518
+ : ''
519
+ const defaultImportName = defaultServiceName
520
+ ? toIdentifier([defaultServiceName, 'Default'])
521
+ : ''
522
+
523
+ if (namedServiceClasses.length === 0 && !defaultServiceName) {
524
+ return
525
+ }
526
+
527
+ if (defaultServiceName) {
528
+ imports.push(`import ${defaultImportName} from '${toImportPath(filePath)}'`)
529
+ exportNames.add(`${defaultImportName} as ${defaultServiceName}`)
530
+ entries.push(` ${toServiceKey(defaultServiceName)}: new ${defaultImportName}(deps),`)
531
+ }
532
+
533
+ if (namedServiceClasses.length > 0) {
534
+ imports.push(`import { ${namedServiceClasses.join(', ')} } from '${toImportPath(filePath)}'`)
535
+ namedServiceClasses.forEach((name) => exportNames.add(name))
536
+ entries.push(...namedServiceClasses.map((name) => ` ${toServiceKey(name)}: new ${name}(deps),`))
537
+ }
538
+ })
539
+
540
+ const exportBlock = formatExportBlock(Array.from(exportNames).sort((left, right) => left.localeCompare(right)))
541
+ const content = `${generatedHeader}
542
+ ${imports.join('\n')}
543
+
544
+ ${exportBlock}
545
+ export function createServices(deps: any) {
546
+ return {
547
+ ${entries.join('\n')}
548
+ }
549
+ }
550
+
551
+ export type Services = ReturnType<typeof createServices>
552
+
553
+ declare module 'jax-hono/core/generated' {
554
+ interface JaxGenerated {
555
+ services: Services
556
+ }
557
+ }
558
+ `
559
+
560
+ writeGeneratedFile('services.generated.ts', content)
561
+ }
562
+
563
+ function generateCronsRegistry() {
564
+ const files = [
565
+ ...getFilesFromDirs(['cron', 'crons'])
566
+ .filter((filePath) => basename(filePath) !== 'index.ts')
567
+ .map((filePath) => ({
568
+ modulePath: toModulePath(filePath.includes(`${srcDir}/crons/`) ? 'crons' : 'cron', filePath, ['.cron']),
569
+ filePath,
570
+ })),
571
+ ...getFilesFromDirs(['modules'])
572
+ .filter((filePath) => isModuleFeatureFile(filePath, '.cron'))
573
+ .map((filePath) => ({
574
+ modulePath: toModulePath('modules', filePath, ['.cron']),
575
+ filePath,
576
+ })),
577
+ ...getEnabledPluginFiles('.cron')
578
+ .map(({ name, rootDir, filePath }) => ({
579
+ modulePath: toPluginModulePath(name, rootDir, filePath, ['.cron']),
580
+ filePath,
581
+ })),
582
+ ]
583
+ .filter(({ filePath }) => hasCronExport(filePath))
584
+ .filter((item, index, list) => {
585
+ return list.findIndex((row) => row.filePath === item.filePath) === index
586
+ })
587
+ .sort((left, right) => left.modulePath.localeCompare(right.modulePath))
588
+
589
+ const imports = files.map((item) => {
590
+ return `import * as ${toLowerCamelIdentifier([item.modulePath], 'Cron')} from '${toImportPath(item.filePath)}'`
591
+ })
592
+ const exportNames = files.map((item) => toLowerCamelIdentifier([item.modulePath], 'Cron'))
593
+ const entries = files.map((item) => {
594
+ const importName = toLowerCamelIdentifier([item.modulePath], 'Cron')
595
+
596
+ return ` { name: ${JSON.stringify(item.modulePath)}, module: ${importName} },`
597
+ })
598
+ const exportBlock = formatExportBlock(exportNames)
599
+ const content = createGeneratedContent([
600
+ imports.join('\n'),
601
+ exportBlock,
602
+ `export const cronModules = [
603
+ ${entries.join('\n')}
604
+ ] as const`,
605
+ ])
606
+
607
+ writeGeneratedFile('crons.generated.ts', content)
608
+ }
609
+
610
+ function hasCronExport(filePath: string) {
611
+ const source = readFileSync(filePath, 'utf8')
612
+
613
+ return /\bexport\s+default\b/.test(source)
614
+ || /\bexport\s+const\s+(?:cron|jobs)\b/.test(source)
615
+ }
616
+
617
+ function findBalancedObject(source: string, startIndex: number) {
618
+ let depth = 0
619
+ let quote: string | undefined
620
+ let escaped = false
621
+ let lineComment = false
622
+ let blockComment = false
623
+
624
+ for (let index = startIndex; index < source.length; index += 1) {
625
+ const char = source[index]
626
+ const next = source[index + 1]
627
+
628
+ if (lineComment) {
629
+ if (char === '\n') {
630
+ lineComment = false
631
+ }
632
+ continue
633
+ }
634
+
635
+ if (blockComment) {
636
+ if (char === '*' && next === '/') {
637
+ blockComment = false
638
+ index += 1
639
+ }
640
+ continue
641
+ }
642
+
643
+ if (quote) {
644
+ if (escaped) {
645
+ escaped = false
646
+ continue
647
+ }
648
+
649
+ if (char === '\\') {
650
+ escaped = true
651
+ continue
652
+ }
653
+
654
+ if (char === quote) {
655
+ quote = undefined
656
+ }
657
+ continue
658
+ }
659
+
660
+ if (char === '/' && next === '/') {
661
+ lineComment = true
662
+ index += 1
663
+ continue
664
+ }
665
+
666
+ if (char === '/' && next === '*') {
667
+ blockComment = true
668
+ index += 1
669
+ continue
670
+ }
671
+
672
+ if (char === '"' || char === '\'' || char === '`') {
673
+ quote = char
674
+ continue
675
+ }
676
+
677
+ if (char === '{') {
678
+ depth += 1
679
+ continue
680
+ }
681
+
682
+ if (char === '}') {
683
+ depth -= 1
684
+
685
+ if (depth === 0) {
686
+ return source.slice(startIndex, index + 1)
687
+ }
688
+ }
689
+ }
690
+
691
+ return undefined
692
+ }
693
+
694
+ function extractPluginConfigObject(source: string) {
695
+ const configCallIndex = source.indexOf('definePluginConfig')
696
+ const searchStart = configCallIndex >= 0 ? configCallIndex : source.indexOf('export default')
697
+ const objectStart = searchStart >= 0 ? source.indexOf('{', searchStart) : -1
698
+
699
+ if (objectStart < 0) {
700
+ return undefined
701
+ }
702
+
703
+ return findBalancedObject(source, objectStart)
704
+ }
705
+
706
+ function readPluginConfig(filePath: string): PluginConfig {
707
+ if (!existsSync(filePath)) {
708
+ return {}
709
+ }
710
+
711
+ const objectSource = extractPluginConfigObject(readFileSync(filePath, 'utf8'))
712
+
713
+ if (!objectSource) {
714
+ return {}
715
+ }
716
+
717
+ try {
718
+ const value = new Function(`return (${objectSource})`)() as unknown
719
+
720
+ return value && typeof value === 'object' && !Array.isArray(value)
721
+ ? (value as PluginConfig)
722
+ : {}
723
+ } catch (error) {
724
+ throw new Error(`Invalid plugin config ${relative(srcDir, filePath)}: ${(error as Error).message}`)
725
+ }
726
+ }
727
+
728
+ function normalizePluginPackage(packageName: string, configFile: string) {
729
+ if (!packageName.startsWith('./') && !packageName.startsWith('../')) {
730
+ return packageName
731
+ }
732
+
733
+ const normalized = join(configFile, '..', packageName)
734
+ .replaceAll('\\', '/')
735
+ .replace(/\/index$/, '')
736
+
737
+ return normalized
738
+ }
739
+
740
+ function mergePluginConfig(configs: { filePath: string; config: PluginConfig }[]) {
741
+ const merged = new Map<string, Exclude<PluginConfigItem, false>>()
742
+
743
+ for (const { filePath, config } of configs) {
744
+ for (const [name, item] of Object.entries(config)) {
745
+ if (item === false) {
746
+ merged.delete(name)
747
+ continue
748
+ }
749
+
750
+ const current = merged.get(name) ?? {}
751
+ const next = { ...current, ...item }
752
+
753
+ if (next.package) {
754
+ next.package = normalizePluginPackage(next.package, filePath)
755
+ }
756
+
757
+ merged.set(name, next)
758
+ }
759
+ }
760
+
761
+ return [...merged.entries()]
762
+ .filter((entry): entry is [string, Exclude<PluginConfigItem, false> & { package: string }] => {
763
+ const [, item] = entry
764
+
765
+ return item.enable === true && typeof item.package === 'string' && item.package.length > 0
766
+ })
767
+ .sort(([left], [right]) => left.localeCompare(right))
768
+ }
769
+
770
+ function getEnabledPluginConfigItems() {
771
+ return mergePluginConfig([
772
+ { filePath: join(packageDir, 'plugins/config.ts'), config: readPluginConfig(join(packageDir, 'plugins/config.ts')) },
773
+ { filePath: join(srcDir, 'config/plugin.ts'), config: readPluginConfig(join(srcDir, 'config/plugin.ts')) },
774
+ ])
775
+ }
776
+
777
+ function resolvePluginPackageDir(packageName: string) {
778
+ const resolved = resolvePluginPackage(packageName)
779
+
780
+ return statSync(resolved).isDirectory()
781
+ ? resolved
782
+ : dirname(resolved)
783
+ }
784
+
785
+ function resolvePluginPackage(packageName: string) {
786
+ if (isAbsolute(packageName)) {
787
+ return packageName
788
+ }
789
+
790
+ if (packageName.startsWith('./') || packageName.startsWith('../')) {
791
+ return resolve(projectDir, packageName)
792
+ }
793
+
794
+ if (packageName.startsWith('jax-hono/')) {
795
+ const packagePath = packageName.slice('jax-hono/'.length)
796
+ const directPath = join(packageDir, packagePath)
797
+
798
+ if (existsSync(directPath)) {
799
+ return directPath
800
+ }
801
+
802
+ const indexPath = join(directPath, 'index.ts')
803
+
804
+ if (existsSync(indexPath)) {
805
+ return indexPath
806
+ }
807
+ }
808
+
809
+ return projectRequire.resolve(packageName)
810
+ }
811
+
812
+ function getEnabledPlugins(): EnabledPlugin[] {
813
+ return getEnabledPluginConfigItems()
814
+ .map(([name, item]) => {
815
+ const rootDir = resolvePluginPackageDir(item.package)
816
+
817
+ return {
818
+ name,
819
+ packageName: item.package,
820
+ rootDir,
821
+ }
822
+ })
823
+ .filter((plugin) => existsSync(plugin.rootDir))
824
+ }
825
+
826
+ function serializePluginOptions(options: unknown) {
827
+ return options === undefined ? '' : `, options: ${JSON.stringify(options)}`
828
+ }
829
+
830
+ function generatePluginContextRegistry() {
831
+ const enabledPlugins = getEnabledPluginConfigItems()
832
+ const imports = enabledPlugins.map(([name, item]) => {
833
+ return `import ${toLowerCamelIdentifier([name], 'Plugin')} from ${JSON.stringify(item.package)}`
834
+ })
835
+ const exportNames = enabledPlugins.map(([name]) => toLowerCamelIdentifier([name], 'Plugin'))
836
+ const moduleEntries = enabledPlugins.map(
837
+ ([name, item]) =>
838
+ ` { name: ${JSON.stringify(name)}, module: ${toLowerCamelIdentifier([name], 'Plugin')}${serializePluginOptions(item.options)} },`,
839
+ )
840
+ const exportBlock = formatExportBlock(exportNames)
841
+
842
+ const content = createGeneratedContent([
843
+ imports.join('\n'),
844
+ exportBlock,
845
+ `export const plugins = {} as Record<string, unknown>
846
+
847
+ export type Plugins = typeof plugins
848
+
849
+ declare module 'jax-hono/core/generated' {
850
+ interface JaxGenerated {
851
+ plugins: Plugins
852
+ }
853
+ }
854
+
855
+ export const pluginModules = [
856
+ ${moduleEntries.join('\n')}
857
+ ] as const`,
858
+ ])
859
+
860
+ writeGeneratedFile('plugins.generated.ts', content)
861
+ }
862
+
863
+ function generateGeneratedEntry() {
864
+ const content = `${generatedHeader}
865
+ import * as configModule from './config'
866
+ import * as controllersModule from './controllers.generated'
867
+ import * as middlewaresModule from './middlewares.generated'
868
+ import * as modelsModule from './models.generated'
869
+ import * as servicesModule from './services.generated'
870
+ import * as cronsModule from './crons.generated'
871
+ import * as pluginsModule from './plugins.generated'
872
+ import { registerGeneratedModules } from 'jax-hono/core/generated'
873
+
874
+ registerGeneratedModules({
875
+ 'config.ts': configModule,
876
+ 'controllers.generated.ts': controllersModule,
877
+ 'middlewares.generated.ts': middlewaresModule,
878
+ 'models.generated.ts': modelsModule,
879
+ 'services.generated.ts': servicesModule,
880
+ 'crons.generated.ts': cronsModule,
881
+ 'plugins.generated.ts': pluginsModule,
882
+ })
883
+
884
+ export * from 'jax-hono'
885
+ `
886
+
887
+ writeGeneratedFile('index.ts', content)
888
+ }
889
+
890
+ export function generateRegistry() {
891
+ generateControllersRegistry()
892
+ generateMiddlewaresRegistry()
893
+ generateModelsRegistry()
894
+ generateServicesRegistry()
895
+ generateCronsRegistry()
896
+ generatePluginContextRegistry()
897
+ generateGeneratedEntry()
898
+ }
899
+
900
+ if (import.meta.main) {
901
+ generateRegistry()
902
+ }