jax-hono 1.0.15 → 1.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/generate-registry.ts +216 -67
- package/core/app.ts +18 -12
- package/core/cron.ts +151 -0
- package/docs/agent.md +45 -0
- package/docs/jax.md +40 -4
- package/index.ts +5 -3
- package/package.json +1 -1
- package/utils/crypto.ts +9 -3
- package/utils/index.ts +5 -3
- package/utils/order.ts +5 -0
- package/utils/regexp.ts +11 -3
- package/utils/validate.ts +7 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
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' },
|
|
@@ -146,8 +154,8 @@ function normalizeModulePath(modulePath: string) {
|
|
|
146
154
|
return modulePath
|
|
147
155
|
}
|
|
148
156
|
|
|
149
|
-
function toModulePath(baseDir: string, filePath: string, suffixes: string[] = []) {
|
|
150
|
-
const modulePath = relative(join(srcDir, baseDir), filePath)
|
|
157
|
+
function toModulePath(baseDir: string, filePath: string, suffixes: string[] = []) {
|
|
158
|
+
const modulePath = relative(join(srcDir, baseDir), filePath)
|
|
151
159
|
.replace(parse(filePath).ext, '')
|
|
152
160
|
.replaceAll('\\', '/')
|
|
153
161
|
const withoutIndex = modulePath.endsWith('/index')
|
|
@@ -156,16 +164,36 @@ function toModulePath(baseDir: string, filePath: string, suffixes: string[] = []
|
|
|
156
164
|
|
|
157
165
|
const stripped = stripKnownSuffix(withoutIndex, suffixes)
|
|
158
166
|
|
|
159
|
-
return baseDir === 'modules' ? normalizeModulePath(stripped) : stripped
|
|
160
|
-
}
|
|
167
|
+
return baseDir === 'modules' ? normalizeModulePath(stripped) : stripped
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function toPluginModulePath(pluginName: string, rootDir: string, filePath: string, suffixes: string[] = []) {
|
|
171
|
+
const modulePath = relative(rootDir, filePath)
|
|
172
|
+
.replace(parse(filePath).ext, '')
|
|
173
|
+
.replaceAll('\\', '/')
|
|
174
|
+
const withoutIndex = modulePath.endsWith('/index')
|
|
175
|
+
? modulePath.slice(0, -'/index'.length)
|
|
176
|
+
: modulePath
|
|
177
|
+
const stripped = stripKnownSuffix(withoutIndex, suffixes)
|
|
178
|
+
|
|
179
|
+
return normalizeModulePath(stripped ? `${pluginName}/${stripped}` : pluginName)
|
|
180
|
+
}
|
|
161
181
|
|
|
162
182
|
function uniqueSorted(values: string[]) {
|
|
163
183
|
return [...new Set(values)].sort((left, right) => left.localeCompare(right))
|
|
164
184
|
}
|
|
165
185
|
|
|
166
|
-
function getFilesFromDirs(dirs: string[]) {
|
|
167
|
-
return uniqueSorted(dirs.flatMap((dirName) => walkFiles(join(srcDir, dirName))))
|
|
168
|
-
}
|
|
186
|
+
function getFilesFromDirs(dirs: string[]) {
|
|
187
|
+
return uniqueSorted(dirs.flatMap((dirName) => walkFiles(join(srcDir, dirName))))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function getEnabledPluginFiles(suffix: string) {
|
|
191
|
+
return getEnabledPlugins().flatMap((plugin) => {
|
|
192
|
+
return walkFiles(plugin.rootDir)
|
|
193
|
+
.filter((filePath) => isSuffixedModuleFile(filePath, suffix))
|
|
194
|
+
.map((filePath) => ({ ...plugin, filePath }))
|
|
195
|
+
})
|
|
196
|
+
}
|
|
169
197
|
|
|
170
198
|
function isFileInDir(filePath: string, dirName: string) {
|
|
171
199
|
const rel = relative(join(srcDir, dirName), filePath)
|
|
@@ -231,12 +259,12 @@ function buildNestedObject(entries: { path: string[]; value: string }[]): string
|
|
|
231
259
|
}
|
|
232
260
|
|
|
233
261
|
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
|
|
262
|
+
const used = new Set<string>()
|
|
263
|
+
const imports: string[] = []
|
|
264
|
+
const groupLines: string[] = []
|
|
265
|
+
|
|
266
|
+
for (const group of controllerGroups) {
|
|
267
|
+
const projectEntries = getFilesFromDirs(group.dirs)
|
|
240
268
|
.filter((filePath) => basename(filePath) !== 'index.ts')
|
|
241
269
|
.filter((filePath) => !group.suffix || isModuleFeatureFile(filePath, group.suffix))
|
|
242
270
|
.filter(hasDefaultExport)
|
|
@@ -248,10 +276,22 @@ function generateControllersRegistry() {
|
|
|
248
276
|
const importName = toIdentifier([group.name, modulePath, String(index), 'Controller'])
|
|
249
277
|
|
|
250
278
|
return { modulePath, importName, filePath }
|
|
251
|
-
})
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
279
|
+
})
|
|
280
|
+
const pluginEntries = group.name === 'controller'
|
|
281
|
+
? getEnabledPluginFiles('.controller')
|
|
282
|
+
.filter(({ filePath }) => basename(filePath) !== 'index.ts')
|
|
283
|
+
.filter(({ filePath }) => hasDefaultExport(filePath))
|
|
284
|
+
.map(({ name, rootDir, filePath }, index) => {
|
|
285
|
+
const modulePath = toPluginModulePath(name, rootDir, filePath, ['.controller'])
|
|
286
|
+
const importName = toIdentifier([group.name, 'plugin', modulePath, String(index), 'Controller'])
|
|
287
|
+
|
|
288
|
+
return { modulePath, importName, filePath }
|
|
289
|
+
})
|
|
290
|
+
: []
|
|
291
|
+
const entries = [...projectEntries, ...pluginEntries]
|
|
292
|
+
.filter((entry) => {
|
|
293
|
+
if (group.name !== 'controller') {
|
|
294
|
+
return true
|
|
255
295
|
}
|
|
256
296
|
|
|
257
297
|
const [scope] = entry.modulePath.split('/')
|
|
@@ -276,18 +316,15 @@ function generateControllersRegistry() {
|
|
|
276
316
|
continue
|
|
277
317
|
}
|
|
278
318
|
|
|
279
|
-
const objectBody =
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
4,
|
|
285
|
-
)
|
|
286
|
-
|
|
319
|
+
const objectBody = buildNestedObject(entries.map((entry) => ({
|
|
320
|
+
path: entry.modulePath.split('/').map(toCamelCase),
|
|
321
|
+
value: `createController(${entry.importName})`,
|
|
322
|
+
})))
|
|
323
|
+
|
|
287
324
|
if (group.name === 'controller') {
|
|
288
|
-
groupLines.push(objectBody)
|
|
325
|
+
groupLines.push(indentObject(objectBody, 2))
|
|
289
326
|
} else {
|
|
290
|
-
groupLines.push(` ${group.name}: {\n${objectBody}\n },`)
|
|
327
|
+
groupLines.push(` ${group.name}: {\n${indentObject(objectBody, 4)}\n },`)
|
|
291
328
|
}
|
|
292
329
|
}
|
|
293
330
|
|
|
@@ -388,21 +425,29 @@ export type Models = typeof models
|
|
|
388
425
|
writeGeneratedFile('models.generated.ts', content)
|
|
389
426
|
}
|
|
390
427
|
|
|
391
|
-
function generateServicesRegistry() {
|
|
392
|
-
const files =
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
const
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
428
|
+
function generateServicesRegistry() {
|
|
429
|
+
const files = [
|
|
430
|
+
...getFilesFromDirs(['services', 'service', 'modules'])
|
|
431
|
+
.filter((filePath) => isModuleFeatureFile(filePath, '.service'))
|
|
432
|
+
.map((filePath) => ({ filePath })),
|
|
433
|
+
...getEnabledPluginFiles('.service'),
|
|
434
|
+
]
|
|
435
|
+
const imports: string[] = []
|
|
436
|
+
const entries: string[] = []
|
|
437
|
+
|
|
438
|
+
files.forEach((item) => {
|
|
439
|
+
const { filePath } = item
|
|
440
|
+
const source = readFileSync(filePath, 'utf8')
|
|
441
|
+
const namedServiceClasses = [...source.matchAll(/\bexport\s+class\s+([a-zA-Z_$][a-zA-Z0-9_$]*Service)\b/g)]
|
|
442
|
+
.map((match) => match[1])
|
|
443
|
+
.sort((left, right) => left.localeCompare(right))
|
|
444
|
+
const defaultServiceClass = source.match(/\bexport\s+default\s+class(?:\s+([a-zA-Z_$][a-zA-Z0-9_$]*))?/)
|
|
445
|
+
const fallbackServicePath = 'name' in item
|
|
446
|
+
? toPluginModulePath(item.name, item.rootDir, filePath, ['.service'])
|
|
447
|
+
: toModulePath('services', filePath)
|
|
448
|
+
const defaultServiceName = defaultServiceClass
|
|
449
|
+
? defaultServiceClass[1] ?? `${toPascalCase(fallbackServicePath)}Service`
|
|
450
|
+
: ''
|
|
406
451
|
const defaultImportName = defaultServiceName
|
|
407
452
|
? toIdentifier([defaultServiceName, 'Default'])
|
|
408
453
|
: ''
|
|
@@ -434,8 +479,58 @@ ${entries.join('\n')}
|
|
|
434
479
|
export type Services = ReturnType<typeof createServices>
|
|
435
480
|
`
|
|
436
481
|
|
|
437
|
-
writeGeneratedFile('services.generated.ts', content)
|
|
438
|
-
}
|
|
482
|
+
writeGeneratedFile('services.generated.ts', content)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function generateCronsRegistry() {
|
|
486
|
+
const files = [
|
|
487
|
+
...getFilesFromDirs(['cron', 'crons'])
|
|
488
|
+
.filter((filePath) => basename(filePath) !== 'index.ts')
|
|
489
|
+
.map((filePath) => ({
|
|
490
|
+
modulePath: toModulePath(filePath.includes(`${srcDir}/crons/`) ? 'crons' : 'cron', filePath, ['.cron']),
|
|
491
|
+
filePath,
|
|
492
|
+
})),
|
|
493
|
+
...getFilesFromDirs(['modules'])
|
|
494
|
+
.filter((filePath) => isModuleFeatureFile(filePath, '.cron'))
|
|
495
|
+
.map((filePath) => ({
|
|
496
|
+
modulePath: toModulePath('modules', filePath, ['.cron']),
|
|
497
|
+
filePath,
|
|
498
|
+
})),
|
|
499
|
+
...getEnabledPluginFiles('.cron')
|
|
500
|
+
.map(({ name, rootDir, filePath }) => ({
|
|
501
|
+
modulePath: toPluginModulePath(name, rootDir, filePath, ['.cron']),
|
|
502
|
+
filePath,
|
|
503
|
+
})),
|
|
504
|
+
]
|
|
505
|
+
.filter(({ filePath }) => hasCronExport(filePath))
|
|
506
|
+
.filter((item, index, list) => {
|
|
507
|
+
return list.findIndex((row) => row.filePath === item.filePath) === index
|
|
508
|
+
})
|
|
509
|
+
.sort((left, right) => left.modulePath.localeCompare(right.modulePath))
|
|
510
|
+
|
|
511
|
+
const imports = files.map((item, index) => {
|
|
512
|
+
return `import * as ${toIdentifier(['cron', item.modulePath, String(index)])} from '${toImportPath(item.filePath)}'`
|
|
513
|
+
})
|
|
514
|
+
const entries = files.map((item, index) => {
|
|
515
|
+
const importName = toIdentifier(['cron', item.modulePath, String(index)])
|
|
516
|
+
|
|
517
|
+
return ` { name: ${JSON.stringify(item.modulePath)}, module: ${importName} },`
|
|
518
|
+
})
|
|
519
|
+
const content = `${generatedHeader}${imports.length > 0 ? `${imports.join('\n')}\n\n` : ''}
|
|
520
|
+
export const cronModules = [
|
|
521
|
+
${entries.join('\n')}
|
|
522
|
+
] as const
|
|
523
|
+
`
|
|
524
|
+
|
|
525
|
+
writeGeneratedFile('crons.generated.ts', content)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function hasCronExport(filePath: string) {
|
|
529
|
+
const source = readFileSync(filePath, 'utf8')
|
|
530
|
+
|
|
531
|
+
return /\bexport\s+default\b/.test(source)
|
|
532
|
+
|| /\bexport\s+const\s+(?:cron|jobs)\b/.test(source)
|
|
533
|
+
}
|
|
439
534
|
|
|
440
535
|
function findBalancedObject(source: string, startIndex: number) {
|
|
441
536
|
let depth = 0
|
|
@@ -560,7 +655,7 @@ function normalizePluginPackage(packageName: string, configFile: string) {
|
|
|
560
655
|
return normalized
|
|
561
656
|
}
|
|
562
657
|
|
|
563
|
-
function mergePluginConfig(configs: { filePath: string; config: PluginConfig }[]) {
|
|
658
|
+
function mergePluginConfig(configs: { filePath: string; config: PluginConfig }[]) {
|
|
564
659
|
const merged = new Map<string, Exclude<PluginConfigItem, false>>()
|
|
565
660
|
|
|
566
661
|
for (const { filePath, config } of configs) {
|
|
@@ -587,18 +682,71 @@ function mergePluginConfig(configs: { filePath: string; config: PluginConfig }[]
|
|
|
587
682
|
|
|
588
683
|
return item.enable === true && typeof item.package === 'string' && item.package.length > 0
|
|
589
684
|
})
|
|
590
|
-
.sort(([left], [right]) => left.localeCompare(right))
|
|
591
|
-
}
|
|
685
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function getEnabledPluginConfigItems() {
|
|
689
|
+
return mergePluginConfig([
|
|
690
|
+
{ filePath: join(packageDir, 'plugins/config.ts'), config: readPluginConfig(join(packageDir, 'plugins/config.ts')) },
|
|
691
|
+
{ filePath: join(srcDir, 'config/plugin.ts'), config: readPluginConfig(join(srcDir, 'config/plugin.ts')) },
|
|
692
|
+
])
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function resolvePluginPackageDir(packageName: string) {
|
|
696
|
+
const resolved = resolvePluginPackage(packageName)
|
|
697
|
+
|
|
698
|
+
return statSync(resolved).isDirectory()
|
|
699
|
+
? resolved
|
|
700
|
+
: dirname(resolved)
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function resolvePluginPackage(packageName: string) {
|
|
704
|
+
if (isAbsolute(packageName)) {
|
|
705
|
+
return packageName
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (packageName.startsWith('./') || packageName.startsWith('../')) {
|
|
709
|
+
return resolve(projectDir, packageName)
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (packageName.startsWith('jax-hono/')) {
|
|
713
|
+
const packagePath = packageName.slice('jax-hono/'.length)
|
|
714
|
+
const directPath = join(packageDir, packagePath)
|
|
715
|
+
|
|
716
|
+
if (existsSync(directPath)) {
|
|
717
|
+
return directPath
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const indexPath = join(directPath, 'index.ts')
|
|
721
|
+
|
|
722
|
+
if (existsSync(indexPath)) {
|
|
723
|
+
return indexPath
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return projectRequire.resolve(packageName)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function getEnabledPlugins(): EnabledPlugin[] {
|
|
731
|
+
return getEnabledPluginConfigItems()
|
|
732
|
+
.map(([name, item]) => {
|
|
733
|
+
const rootDir = resolvePluginPackageDir(item.package)
|
|
734
|
+
|
|
735
|
+
return {
|
|
736
|
+
name,
|
|
737
|
+
packageName: item.package,
|
|
738
|
+
rootDir,
|
|
739
|
+
}
|
|
740
|
+
})
|
|
741
|
+
.filter((plugin) => existsSync(plugin.rootDir))
|
|
742
|
+
}
|
|
592
743
|
|
|
593
744
|
function serializePluginOptions(options: unknown) {
|
|
594
745
|
return options === undefined ? '' : `, options: ${JSON.stringify(options)}`
|
|
595
746
|
}
|
|
596
747
|
|
|
597
|
-
function generatePluginContextRegistry() {
|
|
598
|
-
const enabledPlugins =
|
|
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
|
-
])
|
|
748
|
+
function generatePluginContextRegistry() {
|
|
749
|
+
const enabledPlugins = getEnabledPluginConfigItems()
|
|
602
750
|
const imports = enabledPlugins.map(([name, item]) => {
|
|
603
751
|
return `import ${toIdentifier(['plugin', name])} from ${JSON.stringify(item.package)}`
|
|
604
752
|
})
|
|
@@ -620,13 +768,14 @@ ${moduleEntries.join('\n')}
|
|
|
620
768
|
writeGeneratedFile('plugins.generated.ts', content)
|
|
621
769
|
}
|
|
622
770
|
|
|
623
|
-
export function generateRegistry() {
|
|
624
|
-
generateControllersRegistry()
|
|
625
|
-
generateMiddlewaresRegistry()
|
|
626
|
-
generateModelsRegistry()
|
|
627
|
-
generateServicesRegistry()
|
|
628
|
-
|
|
629
|
-
|
|
771
|
+
export function generateRegistry() {
|
|
772
|
+
generateControllersRegistry()
|
|
773
|
+
generateMiddlewaresRegistry()
|
|
774
|
+
generateModelsRegistry()
|
|
775
|
+
generateServicesRegistry()
|
|
776
|
+
generateCronsRegistry()
|
|
777
|
+
generatePluginContextRegistry()
|
|
778
|
+
}
|
|
630
779
|
|
|
631
780
|
if (import.meta.main) {
|
|
632
781
|
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
|
|
7
|
-
import type {
|
|
8
|
-
import {
|
|
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
|
-
|
|
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
|
-
内置插件配置放在 `
|
|
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
|
|
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
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 "./
|
|
5
|
-
export * from "./
|
|
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
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
|
+
}
|