jax-hono 1.0.15 → 1.0.17

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,10 +1,12 @@
1
- import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
2
- import { cwd } from 'node:process'
3
- import { basename, join, parse, relative } from 'node:path'
4
- import { ensureGeneratedDir, generatedDir, packageDir, projectDir } from './generated-path'
5
-
6
- const srcDir = join(cwd(), 'src')
7
- const moduleExtensions = new Set(['.ts', '.js', '.mjs', '.cjs'])
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'])
8
10
  const generatedHeader = `// This file is generated by jax-hono/build/generate-registry.ts.
9
11
  // Do not edit it directly.
10
12
  `
@@ -23,7 +25,13 @@ type PluginConfigItem =
23
25
  options?: unknown
24
26
  }
25
27
 
26
- type PluginConfig = Record<string, PluginConfigItem>
28
+ type PluginConfig = Record<string, PluginConfigItem>
29
+
30
+ type EnabledPlugin = {
31
+ name: string
32
+ packageName: string
33
+ rootDir: string
34
+ }
27
35
 
28
36
  const controllerGroups: ControllerGroup[] = [
29
37
  { name: 'api', dirs: ['api', 'controllers/api', 'controller/api', 'modules'], suffix: '.api' },
@@ -83,18 +91,36 @@ function toImportPath(filePath: string) {
83
91
  return path
84
92
  }
85
93
 
86
- function toIdentifier(parts: string[]) {
87
- const value = parts
88
- .join('_')
94
+ function toIdentifier(parts: string[]) {
95
+ const value = parts
96
+ .join('_')
89
97
  .replace(/[^a-zA-Z0-9_$]/g, '_')
90
98
  .replace(/^([^a-zA-Z_$])/, '_$1')
91
-
92
- return value || 'module'
93
- }
94
-
95
- function toCamelCase(value: string) {
96
- return value.replace(/[-_]+([a-zA-Z0-9])/g, (_match, char: string) => char.toUpperCase())
97
- }
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
+ }
98
124
 
99
125
  function toPascalCase(value: string) {
100
126
  const camel = toCamelCase(value)
@@ -146,8 +172,8 @@ function normalizeModulePath(modulePath: string) {
146
172
  return modulePath
147
173
  }
148
174
 
149
- function toModulePath(baseDir: string, filePath: string, suffixes: string[] = []) {
150
- const modulePath = relative(join(srcDir, baseDir), filePath)
175
+ function toModulePath(baseDir: string, filePath: string, suffixes: string[] = []) {
176
+ const modulePath = relative(join(srcDir, baseDir), filePath)
151
177
  .replace(parse(filePath).ext, '')
152
178
  .replaceAll('\\', '/')
153
179
  const withoutIndex = modulePath.endsWith('/index')
@@ -156,16 +182,36 @@ function toModulePath(baseDir: string, filePath: string, suffixes: string[] = []
156
182
 
157
183
  const stripped = stripKnownSuffix(withoutIndex, suffixes)
158
184
 
159
- return baseDir === 'modules' ? normalizeModulePath(stripped) : stripped
160
- }
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
+ }
161
199
 
162
200
  function uniqueSorted(values: string[]) {
163
201
  return [...new Set(values)].sort((left, right) => left.localeCompare(right))
164
202
  }
165
203
 
166
- function getFilesFromDirs(dirs: string[]) {
167
- return uniqueSorted(dirs.flatMap((dirName) => walkFiles(join(srcDir, dirName))))
168
- }
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
+ }
169
215
 
170
216
  function isFileInDir(filePath: string, dirName: string) {
171
217
  const rel = relative(join(srcDir, dirName), filePath)
@@ -230,13 +276,14 @@ function buildNestedObject(entries: { path: string[]; value: string }[]): string
230
276
  return lines.join('\n')
231
277
  }
232
278
 
233
- function generateControllersRegistry() {
234
- const used = new Set<string>()
235
- const imports: string[] = []
236
- const groupLines: string[] = []
237
-
238
- for (const group of controllerGroups) {
239
- const entries = getFilesFromDirs(group.dirs)
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)
240
287
  .filter((filePath) => basename(filePath) !== 'index.ts')
241
288
  .filter((filePath) => !group.suffix || isModuleFeatureFile(filePath, group.suffix))
242
289
  .filter(hasDefaultExport)
@@ -246,12 +293,26 @@ function generateControllersRegistry() {
246
293
  }) ?? group.dirs[0]
247
294
  const modulePath = toModulePath(baseDir, filePath, group.suffix ? [group.suffix] : [])
248
295
  const importName = toIdentifier([group.name, modulePath, String(index), 'Controller'])
296
+ const exportName = toLowerCamelIdentifier(group.name === 'controller' ? [modulePath] : [group.name, modulePath], 'Controller')
249
297
 
250
- return { modulePath, importName, filePath }
251
- })
252
- .filter((entry) => {
253
- if (group.name !== 'controller') {
254
- return true
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
255
316
  }
256
317
 
257
318
  const [scope] = entry.modulePath.split('/')
@@ -270,81 +331,106 @@ function generateControllersRegistry() {
270
331
  return true
271
332
  })
272
333
 
273
- imports.push(...entries.map((entry) => `import ${entry.importName} from '${toImportPath(entry.filePath)}'`))
274
-
275
- if (entries.length === 0) {
276
- continue
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
277
339
  }
278
340
 
279
- const objectBody = indentObject(
280
- buildNestedObject(entries.map((entry) => ({
281
- path: entry.modulePath.split('/').map(toCamelCase),
282
- value: `createController(${entry.importName})`,
283
- }))),
284
- 4,
285
- )
286
-
341
+ const objectBody = buildNestedObject(entries.map((entry) => ({
342
+ path: entry.modulePath.split('/').map(toCamelCase),
343
+ value: entry.exportName,
344
+ })))
345
+
287
346
  if (group.name === 'controller') {
288
- groupLines.push(objectBody)
347
+ groupLines.push(indentObject(objectBody, 2))
289
348
  } else {
290
- groupLines.push(` ${group.name}: {\n${objectBody}\n },`)
349
+ groupLines.push(` ${group.name}: {\n${indentObject(objectBody, 4)}\n },`)
291
350
  }
292
351
  }
293
352
 
294
353
  const helperImport = imports.length > 0
295
354
  ? `import { createController } from 'jax-hono/core/controller'\n`
296
355
  : ''
297
- const content = `${generatedHeader}
298
- ${helperImport}${imports.join('\n')}
299
-
300
- export const controllers = {
301
- ${groupLines.join('\n')}
356
+ const content = `${generatedHeader}
357
+ ${helperImport}${imports.join('\n')}
358
+
359
+ ${controllerExports.join('\n')}
360
+
361
+ export const controllers = {
362
+ ${groupLines.join('\n')}
302
363
  }
303
364
  `
304
365
 
305
366
  writeGeneratedFile('controllers.generated.ts', content)
306
367
  }
307
368
 
308
- function toObjectKey(value: string) {
309
- return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(value) ? value : JSON.stringify(value)
310
- }
311
-
312
- function generateMiddlewaresRegistry() {
313
- const files = getFilesFromDirs(['middlewares', 'middleware', 'modules'])
314
- .filter((filePath) => isModuleFeatureFile(filePath, '.middleware'))
315
- const imports: string[] = []
316
- const entries: string[] = []
317
-
318
- files.forEach((filePath, index) => {
319
- const source = readFileSync(filePath, 'utf8')
320
- const exportNames = [...source.matchAll(/\bexport\s+(?:const|function)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g)]
321
- .map((match) => match[1])
322
- .sort((left, right) => left.localeCompare(right))
323
-
324
- if (exportNames.length === 0) {
325
- return
326
- }
327
-
328
- imports.push(`import { ${exportNames.join(', ')} } from '${toImportPath(filePath)}'`)
329
- entries.push(...exportNames.map((name) => ` ${toMiddlewareKey(name)}: ${name},`))
330
- })
331
-
332
- const content = `${generatedHeader}
333
- ${imports.join('\n')}
334
-
335
- export const middlewares = {
336
- ${entries.join('\n')}
337
- }
338
- `
339
-
340
- writeGeneratedFile('middlewares.generated.ts', content)
341
- }
369
+ function toObjectKey(value: string) {
370
+ return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(value) ? value : JSON.stringify(value)
371
+ }
372
+
373
+ function formatExportBlock(names: string[]) {
374
+ if (names.length === 0) {
375
+ return ''
376
+ }
377
+
378
+ return `export {\n${names.map((name) => ` ${name},`).join('\n')}\n}\n`
379
+ }
380
+
381
+ function createGeneratedContent(sections: string[]) {
382
+ return `${generatedHeader}\n${sections
383
+ .map((section) => section.trimEnd())
384
+ .filter((section) => section.trim())
385
+ .join('\n\n')}\n`
386
+ }
387
+
388
+ function generateMiddlewaresRegistry() {
389
+ const files = getFilesFromDirs(['middlewares', 'middleware', 'modules'])
390
+ .filter((filePath) => isModuleFeatureFile(filePath, '.middleware'))
391
+ const imports: string[] = []
392
+ const entries: string[] = []
393
+ const exportNames = new Set<string>()
394
+
395
+ files.forEach((filePath) => {
396
+ const source = readFileSync(filePath, 'utf8')
397
+ const middlewareNames = [...source.matchAll(/\bexport\s+(?:const|function)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)/g)]
398
+ .map((match) => match[1])
399
+ .sort((left, right) => left.localeCompare(right))
400
+
401
+ if (middlewareNames.length === 0) {
402
+ return
403
+ }
404
+
405
+ imports.push(`import { ${middlewareNames.join(', ')} } from '${toImportPath(filePath)}'`)
406
+ middlewareNames.forEach((name) => exportNames.add(name))
407
+ entries.push(...middlewareNames.map((name) => {
408
+ const key = toMiddlewareKey(name)
409
+
410
+ return key === name
411
+ ? ` ${name},`
412
+ : ` ${key}: ${name},`
413
+ }))
414
+ })
415
+
416
+ const exportBlock = formatExportBlock(Array.from(exportNames).sort((left, right) => left.localeCompare(right)))
417
+ const content = createGeneratedContent([
418
+ imports.join('\n'),
419
+ exportBlock,
420
+ `export const middlewares = {
421
+ ${entries.join('\n')}
422
+ }`,
423
+ ])
424
+
425
+ writeGeneratedFile('middlewares.generated.ts', content)
426
+ }
342
427
 
343
428
  function generateModelsRegistry() {
344
- const files = getFilesFromDirs(['models', 'model', 'modules'])
345
- .filter((filePath) => isModuleFeatureFile(filePath, '.model'))
346
- const imports: string[] = []
347
- const entries: string[] = []
429
+ const files = getFilesFromDirs(['models', 'model', 'modules'])
430
+ .filter((filePath) => isModuleFeatureFile(filePath, '.model'))
431
+ const imports: string[] = []
432
+ const entries: string[] = []
433
+ const exportNames = new Set<string>()
348
434
 
349
435
  files.forEach((filePath) => {
350
436
  const source = readFileSync(filePath, 'utf8')
@@ -362,25 +448,31 @@ function generateModelsRegistry() {
362
448
  filePath,
363
449
  ['.model'],
364
450
  ))
365
-
366
- imports.push(`import { ${exportedModels.join(', ')} } from '${toImportPath(filePath)}'`)
367
- entries.push(...exportedModels.map((name) => {
451
+
452
+ imports.push(`import { ${exportedModels.join(', ')} } from '${toImportPath(filePath)}'`)
453
+ exportedModels.forEach((name) => exportNames.add(name))
454
+ entries.push(...exportedModels.map((name) => {
368
455
  const key = exportedModels.length === 1 && modelKey
369
456
  ? modelKey
370
457
  : name === `${fileModelName}Model`
371
458
  ? fileModelName
372
459
  : name
373
460
 
374
- return ` ${key}: ${name},`
375
- }))
376
- })
377
-
378
- const content = `${generatedHeader}
379
- ${imports.join('\n')}
380
-
381
- export const models = {
382
- ${entries.join('\n')}
383
- }
461
+ return key === name
462
+ ? ` ${name},`
463
+ : ` ${key}: ${name},`
464
+ }))
465
+ })
466
+
467
+ const exportBlock = formatExportBlock(Array.from(exportNames).sort((left, right) => left.localeCompare(right)))
468
+
469
+ const content = `${generatedHeader}
470
+ ${imports.join('\n')}
471
+
472
+ ${exportBlock}
473
+ export const models = {
474
+ ${entries.join('\n')}
475
+ }
384
476
 
385
477
  export type Models = typeof models
386
478
  `
@@ -388,21 +480,30 @@ export type Models = typeof models
388
480
  writeGeneratedFile('models.generated.ts', content)
389
481
  }
390
482
 
391
- function generateServicesRegistry() {
392
- const files = getFilesFromDirs(['services', 'service', 'modules'])
393
- .filter((filePath) => isModuleFeatureFile(filePath, '.service'))
394
- const imports: string[] = []
395
- const entries: string[] = []
396
-
397
- files.forEach((filePath) => {
398
- const source = readFileSync(filePath, 'utf8')
399
- const namedServiceClasses = [...source.matchAll(/\bexport\s+class\s+([a-zA-Z_$][a-zA-Z0-9_$]*Service)\b/g)]
400
- .map((match) => match[1])
401
- .sort((left, right) => left.localeCompare(right))
402
- const defaultServiceClass = source.match(/\bexport\s+default\s+class(?:\s+([a-zA-Z_$][a-zA-Z0-9_$]*))?/)
403
- const defaultServiceName = defaultServiceClass
404
- ? defaultServiceClass[1] ?? `${toPascalCase(toModulePath('services', filePath))}Service`
405
- : ''
483
+ function generateServicesRegistry() {
484
+ const files = [
485
+ ...getFilesFromDirs(['services', 'service', 'modules'])
486
+ .filter((filePath) => isModuleFeatureFile(filePath, '.service'))
487
+ .map((filePath) => ({ filePath })),
488
+ ...getEnabledPluginFiles('.service'),
489
+ ]
490
+ const imports: string[] = []
491
+ const entries: string[] = []
492
+ const exportNames = new Set<string>()
493
+
494
+ files.forEach((item) => {
495
+ const { filePath } = item
496
+ const source = readFileSync(filePath, 'utf8')
497
+ const namedServiceClasses = [...source.matchAll(/\bexport\s+class\s+([a-zA-Z_$][a-zA-Z0-9_$]*Service)\b/g)]
498
+ .map((match) => match[1])
499
+ .sort((left, right) => left.localeCompare(right))
500
+ const defaultServiceClass = source.match(/\bexport\s+default\s+class(?:\s+([a-zA-Z_$][a-zA-Z0-9_$]*))?/)
501
+ const fallbackServicePath = 'name' in item
502
+ ? toPluginModulePath(item.name, item.rootDir, filePath, ['.service'])
503
+ : toModulePath('services', filePath)
504
+ const defaultServiceName = defaultServiceClass
505
+ ? defaultServiceClass[1] ?? `${toPascalCase(fallbackServicePath)}Service`
506
+ : ''
406
507
  const defaultImportName = defaultServiceName
407
508
  ? toIdentifier([defaultServiceName, 'Default'])
408
509
  : ''
@@ -411,31 +512,89 @@ function generateServicesRegistry() {
411
512
  return
412
513
  }
413
514
 
414
- if (defaultServiceName) {
415
- imports.push(`import ${defaultImportName} from '${toImportPath(filePath)}'`)
416
- entries.push(` ${toServiceKey(defaultServiceName)}: new ${defaultImportName}(deps),`)
417
- }
418
-
419
- if (namedServiceClasses.length > 0) {
420
- imports.push(`import { ${namedServiceClasses.join(', ')} } from '${toImportPath(filePath)}'`)
421
- entries.push(...namedServiceClasses.map((name) => ` ${toServiceKey(name)}: new ${name}(deps),`))
422
- }
423
- })
424
-
425
- const content = `${generatedHeader}
426
- ${imports.join('\n')}
427
-
428
- export function createServices(deps: any) {
429
- return {
430
- ${entries.join('\n')}
515
+ if (defaultServiceName) {
516
+ imports.push(`import ${defaultImportName} from '${toImportPath(filePath)}'`)
517
+ exportNames.add(`${defaultImportName} as ${defaultServiceName}`)
518
+ entries.push(` ${toServiceKey(defaultServiceName)}: new ${defaultImportName}(deps),`)
519
+ }
520
+
521
+ if (namedServiceClasses.length > 0) {
522
+ imports.push(`import { ${namedServiceClasses.join(', ')} } from '${toImportPath(filePath)}'`)
523
+ namedServiceClasses.forEach((name) => exportNames.add(name))
524
+ entries.push(...namedServiceClasses.map((name) => ` ${toServiceKey(name)}: new ${name}(deps),`))
525
+ }
526
+ })
527
+
528
+ const exportBlock = formatExportBlock(Array.from(exportNames).sort((left, right) => left.localeCompare(right)))
529
+ const content = `${generatedHeader}
530
+ ${imports.join('\n')}
531
+
532
+ ${exportBlock}
533
+ export function createServices(deps: any) {
534
+ return {
535
+ ${entries.join('\n')}
431
536
  }
432
537
  }
433
538
 
434
539
  export type Services = ReturnType<typeof createServices>
435
540
  `
436
541
 
437
- writeGeneratedFile('services.generated.ts', content)
438
- }
542
+ writeGeneratedFile('services.generated.ts', content)
543
+ }
544
+
545
+ function generateCronsRegistry() {
546
+ const files = [
547
+ ...getFilesFromDirs(['cron', 'crons'])
548
+ .filter((filePath) => basename(filePath) !== 'index.ts')
549
+ .map((filePath) => ({
550
+ modulePath: toModulePath(filePath.includes(`${srcDir}/crons/`) ? 'crons' : 'cron', filePath, ['.cron']),
551
+ filePath,
552
+ })),
553
+ ...getFilesFromDirs(['modules'])
554
+ .filter((filePath) => isModuleFeatureFile(filePath, '.cron'))
555
+ .map((filePath) => ({
556
+ modulePath: toModulePath('modules', filePath, ['.cron']),
557
+ filePath,
558
+ })),
559
+ ...getEnabledPluginFiles('.cron')
560
+ .map(({ name, rootDir, filePath }) => ({
561
+ modulePath: toPluginModulePath(name, rootDir, filePath, ['.cron']),
562
+ filePath,
563
+ })),
564
+ ]
565
+ .filter(({ filePath }) => hasCronExport(filePath))
566
+ .filter((item, index, list) => {
567
+ return list.findIndex((row) => row.filePath === item.filePath) === index
568
+ })
569
+ .sort((left, right) => left.modulePath.localeCompare(right.modulePath))
570
+
571
+ const imports = files.map((item) => {
572
+ return `import * as ${toLowerCamelIdentifier([item.modulePath], 'Cron')} from '${toImportPath(item.filePath)}'`
573
+ })
574
+ const exportNames = files.map((item) => toLowerCamelIdentifier([item.modulePath], 'Cron'))
575
+ const entries = files.map((item) => {
576
+ const importName = toLowerCamelIdentifier([item.modulePath], 'Cron')
577
+
578
+ return ` { name: ${JSON.stringify(item.modulePath)}, module: ${importName} },`
579
+ })
580
+ const exportBlock = formatExportBlock(exportNames)
581
+ const content = createGeneratedContent([
582
+ imports.join('\n'),
583
+ exportBlock,
584
+ `export const cronModules = [
585
+ ${entries.join('\n')}
586
+ ] as const`,
587
+ ])
588
+
589
+ writeGeneratedFile('crons.generated.ts', content)
590
+ }
591
+
592
+ function hasCronExport(filePath: string) {
593
+ const source = readFileSync(filePath, 'utf8')
594
+
595
+ return /\bexport\s+default\b/.test(source)
596
+ || /\bexport\s+const\s+(?:cron|jobs)\b/.test(source)
597
+ }
439
598
 
440
599
  function findBalancedObject(source: string, startIndex: number) {
441
600
  let depth = 0
@@ -560,7 +719,7 @@ function normalizePluginPackage(packageName: string, configFile: string) {
560
719
  return normalized
561
720
  }
562
721
 
563
- function mergePluginConfig(configs: { filePath: string; config: PluginConfig }[]) {
722
+ function mergePluginConfig(configs: { filePath: string; config: PluginConfig }[]) {
564
723
  const merged = new Map<string, Exclude<PluginConfigItem, false>>()
565
724
 
566
725
  for (const { filePath, config } of configs) {
@@ -587,46 +746,104 @@ function mergePluginConfig(configs: { filePath: string; config: PluginConfig }[]
587
746
 
588
747
  return item.enable === true && typeof item.package === 'string' && item.package.length > 0
589
748
  })
590
- .sort(([left], [right]) => left.localeCompare(right))
591
- }
749
+ .sort(([left], [right]) => left.localeCompare(right))
750
+ }
751
+
752
+ function getEnabledPluginConfigItems() {
753
+ return mergePluginConfig([
754
+ { filePath: join(packageDir, 'plugins/config.ts'), config: readPluginConfig(join(packageDir, 'plugins/config.ts')) },
755
+ { filePath: join(srcDir, 'config/plugin.ts'), config: readPluginConfig(join(srcDir, 'config/plugin.ts')) },
756
+ ])
757
+ }
758
+
759
+ function resolvePluginPackageDir(packageName: string) {
760
+ const resolved = resolvePluginPackage(packageName)
761
+
762
+ return statSync(resolved).isDirectory()
763
+ ? resolved
764
+ : dirname(resolved)
765
+ }
766
+
767
+ function resolvePluginPackage(packageName: string) {
768
+ if (isAbsolute(packageName)) {
769
+ return packageName
770
+ }
771
+
772
+ if (packageName.startsWith('./') || packageName.startsWith('../')) {
773
+ return resolve(projectDir, packageName)
774
+ }
775
+
776
+ if (packageName.startsWith('jax-hono/')) {
777
+ const packagePath = packageName.slice('jax-hono/'.length)
778
+ const directPath = join(packageDir, packagePath)
779
+
780
+ if (existsSync(directPath)) {
781
+ return directPath
782
+ }
783
+
784
+ const indexPath = join(directPath, 'index.ts')
785
+
786
+ if (existsSync(indexPath)) {
787
+ return indexPath
788
+ }
789
+ }
790
+
791
+ return projectRequire.resolve(packageName)
792
+ }
793
+
794
+ function getEnabledPlugins(): EnabledPlugin[] {
795
+ return getEnabledPluginConfigItems()
796
+ .map(([name, item]) => {
797
+ const rootDir = resolvePluginPackageDir(item.package)
798
+
799
+ return {
800
+ name,
801
+ packageName: item.package,
802
+ rootDir,
803
+ }
804
+ })
805
+ .filter((plugin) => existsSync(plugin.rootDir))
806
+ }
592
807
 
593
808
  function serializePluginOptions(options: unknown) {
594
809
  return options === undefined ? '' : `, options: ${JSON.stringify(options)}`
595
810
  }
596
811
 
597
- function generatePluginContextRegistry() {
598
- const enabledPlugins = mergePluginConfig([
599
- { filePath: join(packageDir, 'plugins/config.ts'), config: readPluginConfig(join(packageDir, 'plugins/config.ts')) },
600
- { filePath: join(srcDir, 'config/plugin.ts'), config: readPluginConfig(join(srcDir, 'config/plugin.ts')) },
601
- ])
602
- const imports = enabledPlugins.map(([name, item]) => {
603
- return `import ${toIdentifier(['plugin', name])} from ${JSON.stringify(item.package)}`
604
- })
605
- const moduleEntries = enabledPlugins.map(
606
- ([name, item]) =>
607
- ` { name: ${JSON.stringify(name)}, module: ${toIdentifier(['plugin', name])}${serializePluginOptions(item.options)} },`,
608
- )
609
-
610
- const content = `${generatedHeader}${imports.length > 0 ? `${imports.join('\n')}\n\n` : ''}
611
- export const plugins = {} as Record<string, unknown>
612
-
613
- export type Plugins = typeof plugins
614
-
615
- export const pluginModules = [
616
- ${moduleEntries.join('\n')}
617
- ] as const
618
- `
619
-
620
- writeGeneratedFile('plugins.generated.ts', content)
621
- }
812
+ function generatePluginContextRegistry() {
813
+ const enabledPlugins = getEnabledPluginConfigItems()
814
+ const imports = enabledPlugins.map(([name, item]) => {
815
+ return `import ${toLowerCamelIdentifier([name], 'Plugin')} from ${JSON.stringify(item.package)}`
816
+ })
817
+ const exportNames = enabledPlugins.map(([name]) => toLowerCamelIdentifier([name], 'Plugin'))
818
+ const moduleEntries = enabledPlugins.map(
819
+ ([name, item]) =>
820
+ ` { name: ${JSON.stringify(name)}, module: ${toLowerCamelIdentifier([name], 'Plugin')}${serializePluginOptions(item.options)} },`,
821
+ )
822
+ const exportBlock = formatExportBlock(exportNames)
823
+
824
+ const content = createGeneratedContent([
825
+ imports.join('\n'),
826
+ exportBlock,
827
+ `export const plugins = {} as Record<string, unknown>
828
+
829
+ export type Plugins = typeof plugins
830
+
831
+ export const pluginModules = [
832
+ ${moduleEntries.join('\n')}
833
+ ] as const`,
834
+ ])
835
+
836
+ writeGeneratedFile('plugins.generated.ts', content)
837
+ }
622
838
 
623
- export function generateRegistry() {
624
- generateControllersRegistry()
625
- generateMiddlewaresRegistry()
626
- generateModelsRegistry()
627
- generateServicesRegistry()
628
- generatePluginContextRegistry()
629
- }
839
+ export function generateRegistry() {
840
+ generateControllersRegistry()
841
+ generateMiddlewaresRegistry()
842
+ generateModelsRegistry()
843
+ generateServicesRegistry()
844
+ generateCronsRegistry()
845
+ generatePluginContextRegistry()
846
+ }
630
847
 
631
848
  if (import.meta.main) {
632
849
  generateRegistry()
package/core/app.ts CHANGED
@@ -2,10 +2,12 @@ import { createHono, createApi } from './hono';
2
2
  import { registerCrudRoutes } from '../helpers/crud';
3
3
  import config from '../config';
4
4
  import type { Config } from '../config';
5
- import { pluginModules } from '../generated/plugins.generated';
6
- import type { models as generatedModels } from '../generated/models.generated';
7
- import type { createServices as generatedCreateServices } from '../generated/services.generated';
8
- import { runWithJaxContext } from './controller';
5
+ import { pluginModules } from '../generated/plugins.generated';
6
+ import { cronModules } from '../generated/crons.generated';
7
+ import type { models as generatedModels } from '../generated/models.generated';
8
+ import type { createServices as generatedCreateServices } from '../generated/services.generated';
9
+ import { runWithJaxContext } from './controller';
10
+ import { startCronJobs, stopCronJobs } from './cron';
9
11
  import { requestLog } from '../middleware/request-log';
10
12
  import type { Hono } from 'hono';
11
13
  import type { JaxHono } from './hono';
@@ -65,7 +67,8 @@ export function createApp(deps: AppRuntimeDeps): JaxHono {
65
67
  service: undefined as unknown as Services,
66
68
  plugin: {} as Plugins
67
69
  };
68
- const activePlugins: ActivePlugin[] = [];
70
+ const activePlugins: ActivePlugin[] = [];
71
+ let activeCrons: ReturnType<typeof startCronJobs> = [];
69
72
 
70
73
  ctx.service = deps.createServices(ctx);
71
74
  app.jax = ctx;
@@ -91,16 +94,19 @@ export function createApp(deps: AppRuntimeDeps): JaxHono {
91
94
  ).then(async (plugins) => {
92
95
  activePlugins.push(...plugins.filter((plugin): plugin is ActivePlugin => Boolean(plugin)));
93
96
 
94
- printEnabledPlugins(activePlugins);
95
-
96
- await Promise.all(activePlugins.map((plugin) => runPluginReadySafely(plugin, ctx)));
97
- });
97
+ printEnabledPlugins(activePlugins);
98
+
99
+ await Promise.all(activePlugins.map((plugin) => runPluginReadySafely(plugin, ctx)));
100
+
101
+ activeCrons = startCronJobs(cronModules, ctx);
102
+ });
98
103
 
99
104
  app.ready = ready;
100
105
  app.close = async () => {
101
- await ready.catch(() => undefined);
102
- await closePluginsSafely(activePlugins, ctx);
103
- };
106
+ await ready.catch(() => undefined);
107
+ stopCronJobs(activeCrons);
108
+ await closePluginsSafely(activePlugins, ctx);
109
+ };
104
110
 
105
111
  return app;
106
112
  }
package/core/cron.ts ADDED
@@ -0,0 +1,151 @@
1
+ import type { AppContext } from './app';
2
+
3
+ type MaybePromise<T> = T | Promise<T>;
4
+
5
+ export type CronRunContext = AppContext & {
6
+ signal: AbortSignal;
7
+ };
8
+
9
+ export type CronSchedule = {
10
+ /**
11
+ * Cron expression supported by Bun.cron.
12
+ */
13
+ cron: string;
14
+ /**
15
+ * Run once immediately after the app is ready.
16
+ */
17
+ immediate?: boolean;
18
+ };
19
+
20
+ export type CronJob = CronSchedule & {
21
+ name?: string;
22
+ run: (ctx: CronRunContext) => MaybePromise<void>;
23
+ };
24
+
25
+ type CronModule =
26
+ | CronJob
27
+ | CronJob[]
28
+ | {
29
+ default?: CronJob | CronJob[];
30
+ cron?: CronJob | CronJob[];
31
+ jobs?: CronJob[];
32
+ };
33
+
34
+ type CronModuleItem = {
35
+ name: string;
36
+ module: CronModule;
37
+ };
38
+
39
+ type ActiveCron = {
40
+ name: string;
41
+ cron: string;
42
+ nextRunAt?: Date;
43
+ stop: () => void;
44
+ };
45
+
46
+ export function defineCron(job: CronJob) {
47
+ return job;
48
+ }
49
+
50
+ export function startCronJobs(items: readonly CronModuleItem[], ctx: AppContext) {
51
+ const active: ActiveCron[] = [];
52
+
53
+ for (const item of items) {
54
+ for (const job of resolveCronJobs(item.module)) {
55
+ active.push(startCronJob(item.name, job, ctx));
56
+ }
57
+ }
58
+
59
+ printCronJobs(active);
60
+
61
+ return active;
62
+ }
63
+
64
+ export function stopCronJobs(items: readonly ActiveCron[]) {
65
+ for (const item of [...items].reverse()) {
66
+ item.stop();
67
+ }
68
+ }
69
+
70
+ function resolveCronJobs(module: CronModule): CronJob[] {
71
+ if (Array.isArray(module)) {
72
+ return module;
73
+ }
74
+
75
+ if (isCronJob(module)) {
76
+ return [module];
77
+ }
78
+
79
+ const jobs = module.default ?? module.cron ?? module.jobs ?? [];
80
+
81
+ return Array.isArray(jobs) ? jobs : [jobs];
82
+ }
83
+
84
+ function isCronJob(value: unknown): value is CronJob {
85
+ return Boolean(value && typeof value === 'object' && typeof (value as CronJob).run === 'function');
86
+ }
87
+
88
+ function startCronJob(moduleName: string, job: CronJob, appContext: AppContext): ActiveCron {
89
+ const name = job.name || moduleName;
90
+ const controller = new AbortController();
91
+ let running = false;
92
+
93
+ const run = async () => {
94
+ if (running || controller.signal.aborted) {
95
+ return;
96
+ }
97
+
98
+ running = true;
99
+
100
+ try {
101
+ await job.run({ ...appContext, signal: controller.signal });
102
+ } catch (error) {
103
+ console.error(`[cron] ${name} failed:`, error);
104
+ } finally {
105
+ running = false;
106
+ }
107
+ };
108
+ const cronJob = Bun.cron(job.cron, () => {
109
+ void run();
110
+ });
111
+
112
+ if (job.immediate) {
113
+ void run();
114
+ }
115
+
116
+ return {
117
+ name,
118
+ cron: job.cron,
119
+ nextRunAt: getNextRunAt(job.cron),
120
+ stop() {
121
+ controller.abort();
122
+ cronJob.stop();
123
+ },
124
+ };
125
+ }
126
+
127
+ function getNextRunAt(expression: string) {
128
+ try {
129
+ return Bun.cron.parse(expression) ?? undefined;
130
+ } catch (error) {
131
+ console.error(`[cron] invalid expression ${expression}:`, error);
132
+ return undefined;
133
+ }
134
+ }
135
+
136
+ function printCronJobs(items: readonly ActiveCron[]) {
137
+ if (items.length === 0) {
138
+ console.log('[cron] enabled: none');
139
+ return;
140
+ }
141
+
142
+ console.log('[cron] enabled:');
143
+
144
+ for (const item of items) {
145
+ const nextRunAt = item.nextRunAt
146
+ ? `${item.nextRunAt.toLocaleString()} (${item.nextRunAt.toISOString()})`
147
+ : 'unknown';
148
+
149
+ console.log(` - ${item.name} (${item.cron}) next: ${nextRunAt}`);
150
+ }
151
+ }
package/docs/agent.md CHANGED
@@ -48,6 +48,51 @@ generated/plugins.generated.ts
48
48
 
49
49
  如果业务仓库里有多个工程都依赖 `jax-hono`,分别查看各工程的 `package.json`,运行它们自己的 setup/build 命令。
50
50
 
51
+ ## 插件生成约定
52
+
53
+ 生成器只扫描已启用插件。插件启用来源包括:
54
+
55
+ - `jax-hono/plugins/config.ts`
56
+ - 使用方工程的 `src/config/plugin.ts`
57
+
58
+ 未启用插件不应进入生成结果,避免构建解析未启用插件的运行时依赖。
59
+
60
+ 已启用插件中的约定文件会进入生成器:
61
+
62
+ - `*.controller.ts` 注册到 `controllers` 根对象
63
+ - `*.service.ts` 注册到 `services.generated.ts`
64
+ - `*.cron.ts` 注册到 `crons.generated.ts`,由框架统一启动/停止
65
+
66
+ 例如启用插件 `foo` 后:
67
+
68
+ ```text
69
+ plugins/foo/foo.controller.ts
70
+ plugins/foo/foo-cache.service.ts
71
+ plugins/foo/foo-cleanup.cron.ts
72
+ ```
73
+
74
+ 可生成:
75
+
76
+ ```ts
77
+ controllers.foo
78
+ service.fooCache
79
+ ```
80
+
81
+ Cron 文件可以使用 `defineCron`:
82
+
83
+ ```ts
84
+ import { defineCron } from 'jax-hono';
85
+
86
+ export default defineCron({
87
+ name: 'foo-cleanup',
88
+ cron: '0 0 * * *',
89
+ immediate: true,
90
+ async run(ctx) {
91
+ // ctx.app / ctx.config / ctx.model / ctx.service / ctx.plugin / ctx.signal
92
+ }
93
+ });
94
+ ```
95
+
51
96
  ## Controller 注册约定
52
97
 
53
98
  生成器需要保持不同命名空间的边界清晰:
package/docs/jax.md CHANGED
@@ -91,9 +91,10 @@ export default definePlugin({
91
91
  - `generated/config.ts`
92
92
  - `generated/controllers.generated.ts`
93
93
  - `generated/middlewares.generated.ts`
94
- - `generated/models.generated.ts`
95
- - `generated/plugins.generated.ts`
96
- - `generated/services.generated.ts`
94
+ - `generated/models.generated.ts`
95
+ - `generated/plugins.generated.ts`
96
+ - `generated/services.generated.ts`
97
+ - `generated/crons.generated.ts`
97
98
 
98
99
  业务代码不要手动编辑 `generated` 目录。
99
100
 
@@ -259,7 +260,42 @@ Service 中推荐使用:
259
260
 
260
261
  新增文件后,如果业务侧需要使用,应优先在对应子入口导出,例如 `src/jax/utils/index.ts`、`src/jax/errors/index.ts`。只有框架核心 API 才放到 `src/jax/index.ts`。
261
262
 
262
- 内置插件配置放在 `src/jax/plugins/config.ts`,内置插件实现放在 `src/jax/plugins/*`,项目级插件通过 `src/config/plugin.ts` 显式启用或覆盖。生成器只读取插件配置,不扫描插件目录。
263
+ 内置插件配置放在 `plugins/config.ts`,内置插件实现放在 `plugins/*`,项目级插件通过 `src/config/plugin.ts` 显式启用或覆盖。生成器只扫描已启用插件中的约定文件,不扫描未启用插件。
264
+
265
+ 已启用插件可以提供:
266
+
267
+ - `*.controller.ts`:注册到 `controllers` 根对象
268
+ - `*.service.ts`:注册到 `services.generated.ts`
269
+ - `*.cron.ts`:注册到 `crons.generated.ts`,由框架统一启动/停止
270
+
271
+ 未启用插件不会进入生成结果,也不会让构建解析它的运行时依赖。
272
+
273
+ ### `cron`
274
+
275
+ 定时任务可以放在项目根级 `src/cron` / `src/crons` 目录,也可以放在模块或已启用插件中:
276
+
277
+ ```text
278
+ src/cron/report.cron.ts
279
+ src/modules/oss/oss-trash.cron.ts
280
+ plugins/foo/foo-cleanup.cron.ts
281
+ ```
282
+
283
+ Cron 文件导出 `defineCron()`、`cron` 或 `jobs` 即可被生成器识别:
284
+
285
+ ```ts
286
+ import { defineCron } from 'jax-hono';
287
+
288
+ export default defineCron({
289
+ name: 'daily-cleanup',
290
+ cron: '0 0 * * *',
291
+ immediate: true,
292
+ async run(ctx) {
293
+ // ctx.app / ctx.config / ctx.model / ctx.service / ctx.plugin / ctx.signal
294
+ }
295
+ });
296
+ ```
297
+
298
+ 框架会在应用 ready 后统一启动定时任务,在 `app.close()` 时统一停止,不需要每个任务在入口文件手动声明。
263
299
 
264
300
  ## 常用命令
265
301
 
package/index.ts CHANGED
@@ -61,9 +61,11 @@ export type { AppContext, AppPlugin } from './core/app';
61
61
  export type { Config, JaxConfig } from './config';
62
62
  export { ApiController } from './core/api-controller';
63
63
  export { Controller } from './core/controller';
64
- export { Service } from './core/service';
65
- export type { ServiceContext } from './core/service';
66
- export type { Services } from './generated/services.generated';
64
+ export { Service } from './core/service';
65
+ export type { ServiceContext } from './core/service';
66
+ export { defineCron } from './core/cron';
67
+ export type { CronJob, CronRunContext, CronSchedule } from './core/cron';
68
+ export type { Services } from './generated/services.generated';
67
69
  export type { ApiFailResponse, ApiResponse, ApiSuccessResponse } from './middleware/api-response';
68
70
 
69
71
  export function createApp() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jax-hono",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "Lightweight framework layer on top of Hono, built for Bun",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
package/utils/crypto.ts CHANGED
@@ -4,6 +4,12 @@ export function md5(value: string) {
4
4
  return createHash('md5').update(value).digest('hex');
5
5
  }
6
6
 
7
- export function createToken(byteLength = 24) {
8
- return randomBytes(byteLength).toString('hex');
9
- }
7
+ export function createToken(byteLength = 24) {
8
+ return randomBytes(byteLength).toString('hex');
9
+ }
10
+
11
+ export function nonceStr(length: number) {
12
+ return randomBytes(Math.ceil(length / 2))
13
+ .toString('hex')
14
+ .slice(0, length);
15
+ }
package/utils/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export * from "./array";
2
2
  export * from "./async";
3
- export * from "./crypto";
4
- export * from "./regexp";
5
- export * from "./transform";
3
+ export * from "./crypto";
4
+ export * from "./order";
5
+ export * from "./regexp";
6
+ export * from "./transform";
7
+ export * from "./validate";
package/utils/order.ts ADDED
@@ -0,0 +1,5 @@
1
+ import dayjs from 'dayjs';
2
+
3
+ export function getOrderNo(prefix = '') {
4
+ return `${prefix}${dayjs().format('YYYYMMDDHHmmssSSS')}${Math.floor(Math.random() * 9000 + 1000)}`;
5
+ }
package/utils/regexp.ts CHANGED
@@ -12,6 +12,14 @@
12
12
  * escapeRegex('price$') // => 'price\\$'
13
13
  * escapeRegex(123) // => '123'
14
14
  */
15
- export function escapeRegex(value: unknown) {
16
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
17
- }
15
+ export function escapeRegex(value: unknown) {
16
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
17
+ }
18
+
19
+ export function keywordRegex(keyword?: string) {
20
+ if (!keyword) {
21
+ return undefined;
22
+ }
23
+
24
+ return new RegExp(escapeRegex(keyword), 'i');
25
+ }
@@ -0,0 +1,7 @@
1
+ export function requireFields(body: Record<string, unknown>, fields: string[]) {
2
+ for (const field of fields) {
3
+ if (body[field] === undefined || body[field] === null || body[field] === '') {
4
+ throw new Error(`缺少${field}`);
5
+ }
6
+ }
7
+ }