jax-hono 1.0.14 → 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/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # Jax-hono
2
2
 
3
- Jax 是本项目的轻量框架层。完整说明见 [docs/jax.md](docs/jax.md)。
3
+ Jax 是本项目的轻量框架层。完整说明见 [docs/jax.md](docs/jax.md)。
4
+
5
+ 给 Codex、Claude 等代码代理看的维护说明见 [docs/agent.md](docs/agent.md)。
4
6
 
5
7
  维护 Jax 内部代码时按目录职责放置文件:
6
8
 
@@ -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,14 +25,20 @@ 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' },
30
38
  { name: 'backend', dirs: ['backend', 'controllers/backend', 'controller/backend', 'modules'], suffix: '.backend' },
31
39
  { name: 'open', dirs: ['open', 'controllers/open', 'controller/open', 'modules'], suffix: '.open' },
32
40
  { name: 'client', dirs: ['client', 'controllers/client', 'controller/client', 'modules'], suffix: '.client' },
33
- { name: 'controller', dirs: ['controllers', 'controller'] },
41
+ { name: 'controller', dirs: ['controllers', 'controller', 'modules'], suffix: '.controller' },
34
42
  ]
35
43
 
36
44
  const scopedControllerGroups = new Set(
@@ -126,18 +134,28 @@ function stripKnownSuffix(modulePath: string, suffixes: string[]) {
126
134
  return modulePath
127
135
  }
128
136
 
129
- function normalizeModulePath(modulePath: string) {
130
- const parts = modulePath.split('/')
131
- const last = parts.at(-1)
132
- const parent = parts.at(-2)
133
-
134
- return last && parent && last === parent
135
- ? parts.slice(0, -1).join('/')
136
- : modulePath
137
- }
138
-
139
- function toModulePath(baseDir: string, filePath: string, suffixes: string[] = []) {
140
- const modulePath = relative(join(srcDir, baseDir), filePath)
137
+ function normalizeModulePath(modulePath: string) {
138
+ const parts = modulePath.split('/')
139
+ const last = parts.at(-1)
140
+ const parent = parts.at(-2)
141
+
142
+ if (!last || !parent) {
143
+ return modulePath
144
+ }
145
+
146
+ if (last === parent) {
147
+ return parts.slice(0, -1).join('/')
148
+ }
149
+
150
+ if (last.startsWith(`${parent}-`) || last.startsWith(`${parent}_`)) {
151
+ return [...parts.slice(0, -2), last].join('/')
152
+ }
153
+
154
+ return modulePath
155
+ }
156
+
157
+ function toModulePath(baseDir: string, filePath: string, suffixes: string[] = []) {
158
+ const modulePath = relative(join(srcDir, baseDir), filePath)
141
159
  .replace(parse(filePath).ext, '')
142
160
  .replaceAll('\\', '/')
143
161
  const withoutIndex = modulePath.endsWith('/index')
@@ -146,16 +164,36 @@ function toModulePath(baseDir: string, filePath: string, suffixes: string[] = []
146
164
 
147
165
  const stripped = stripKnownSuffix(withoutIndex, suffixes)
148
166
 
149
- return baseDir === 'modules' ? normalizeModulePath(stripped) : stripped
150
- }
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
+ }
151
181
 
152
182
  function uniqueSorted(values: string[]) {
153
183
  return [...new Set(values)].sort((left, right) => left.localeCompare(right))
154
184
  }
155
185
 
156
- function getFilesFromDirs(dirs: string[]) {
157
- return uniqueSorted(dirs.flatMap((dirName) => walkFiles(join(srcDir, dirName))))
158
- }
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
+ }
159
197
 
160
198
  function isFileInDir(filePath: string, dirName: string) {
161
199
  const rel = relative(join(srcDir, dirName), filePath)
@@ -170,13 +208,13 @@ function isSuffixedModuleFile(filePath: string, suffix: string) {
170
208
  .endsWith(suffix)
171
209
  }
172
210
 
173
- function isModuleFeatureFile(filePath: string, suffix: string) {
174
- return !isFileInDir(filePath, 'modules') || isSuffixedModuleFile(filePath, suffix)
175
- }
176
-
177
- function hasDefaultExport(filePath: string) {
178
- return /\bexport\s+default\b/.test(readFileSync(filePath, 'utf8'))
179
- }
211
+ function isModuleFeatureFile(filePath: string, suffix: string) {
212
+ return !isFileInDir(filePath, 'modules') || isSuffixedModuleFile(filePath, suffix)
213
+ }
214
+
215
+ function hasDefaultExport(filePath: string) {
216
+ return /\bexport\s+default\b/.test(readFileSync(filePath, 'utf8'))
217
+ }
180
218
 
181
219
  function indentObject(content: string, spaces = 2) {
182
220
  if (!content) {
@@ -221,27 +259,39 @@ function buildNestedObject(entries: { path: string[]; value: string }[]): string
221
259
  }
222
260
 
223
261
  function generateControllersRegistry() {
224
- const used = new Set<string>()
225
- const imports: string[] = []
226
- const groupLines: string[] = []
227
-
228
- for (const group of controllerGroups) {
229
- const entries = getFilesFromDirs(group.dirs)
230
- .filter((filePath) => basename(filePath) !== 'index.ts')
231
- .filter((filePath) => !group.suffix || isModuleFeatureFile(filePath, group.suffix))
232
- .filter(hasDefaultExport)
233
- .map((filePath, index) => {
234
- const baseDir = group.dirs.find((dirName) => {
235
- return isFileInDir(filePath, dirName)
236
- }) ?? group.dirs[0]
237
- const modulePath = toModulePath(baseDir, filePath, group.suffix ? [group.suffix] : [])
238
- const importName = toIdentifier([group.name, modulePath, String(index), 'Controller'])
239
-
240
- return { modulePath, importName, filePath }
241
- })
242
- .filter((entry) => {
243
- if (group.name !== 'controller') {
244
- return true
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)
268
+ .filter((filePath) => basename(filePath) !== 'index.ts')
269
+ .filter((filePath) => !group.suffix || isModuleFeatureFile(filePath, group.suffix))
270
+ .filter(hasDefaultExport)
271
+ .map((filePath, index) => {
272
+ const baseDir = group.dirs.find((dirName) => {
273
+ return isFileInDir(filePath, dirName)
274
+ }) ?? group.dirs[0]
275
+ const modulePath = toModulePath(baseDir, filePath, group.suffix ? [group.suffix] : [])
276
+ const importName = toIdentifier([group.name, modulePath, String(index), 'Controller'])
277
+
278
+ return { modulePath, importName, filePath }
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
245
295
  }
246
296
 
247
297
  const [scope] = entry.modulePath.split('/')
@@ -266,16 +316,17 @@ function generateControllersRegistry() {
266
316
  continue
267
317
  }
268
318
 
269
- const objectBody = indentObject(
270
- buildNestedObject(entries.map((entry) => ({
271
- path: entry.modulePath.split('/').map(toCamelCase),
272
- value: `createController(${entry.importName})`,
273
- }))),
274
- 4,
275
- )
276
-
277
- groupLines.push(` ${group.name}: {\n${objectBody}\n },`)
278
- }
319
+ const objectBody = buildNestedObject(entries.map((entry) => ({
320
+ path: entry.modulePath.split('/').map(toCamelCase),
321
+ value: `createController(${entry.importName})`,
322
+ })))
323
+
324
+ if (group.name === 'controller') {
325
+ groupLines.push(indentObject(objectBody, 2))
326
+ } else {
327
+ groupLines.push(` ${group.name}: {\n${indentObject(objectBody, 4)}\n },`)
328
+ }
329
+ }
279
330
 
280
331
  const helperImport = imports.length > 0
281
332
  ? `import { createController } from 'jax-hono/core/controller'\n`
@@ -374,21 +425,29 @@ export type Models = typeof models
374
425
  writeGeneratedFile('models.generated.ts', content)
375
426
  }
376
427
 
377
- function generateServicesRegistry() {
378
- const files = getFilesFromDirs(['services', 'service', 'modules'])
379
- .filter((filePath) => isModuleFeatureFile(filePath, '.service'))
380
- const imports: string[] = []
381
- const entries: string[] = []
382
-
383
- files.forEach((filePath) => {
384
- const source = readFileSync(filePath, 'utf8')
385
- const namedServiceClasses = [...source.matchAll(/\bexport\s+class\s+([a-zA-Z_$][a-zA-Z0-9_$]*Service)\b/g)]
386
- .map((match) => match[1])
387
- .sort((left, right) => left.localeCompare(right))
388
- const defaultServiceClass = source.match(/\bexport\s+default\s+class(?:\s+([a-zA-Z_$][a-zA-Z0-9_$]*))?/)
389
- const defaultServiceName = defaultServiceClass
390
- ? defaultServiceClass[1] ?? `${toPascalCase(toModulePath('services', filePath))}Service`
391
- : ''
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
+ : ''
392
451
  const defaultImportName = defaultServiceName
393
452
  ? toIdentifier([defaultServiceName, 'Default'])
394
453
  : ''
@@ -420,8 +479,58 @@ ${entries.join('\n')}
420
479
  export type Services = ReturnType<typeof createServices>
421
480
  `
422
481
 
423
- writeGeneratedFile('services.generated.ts', content)
424
- }
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
+ }
425
534
 
426
535
  function findBalancedObject(source: string, startIndex: number) {
427
536
  let depth = 0
@@ -546,7 +655,7 @@ function normalizePluginPackage(packageName: string, configFile: string) {
546
655
  return normalized
547
656
  }
548
657
 
549
- function mergePluginConfig(configs: { filePath: string; config: PluginConfig }[]) {
658
+ function mergePluginConfig(configs: { filePath: string; config: PluginConfig }[]) {
550
659
  const merged = new Map<string, Exclude<PluginConfigItem, false>>()
551
660
 
552
661
  for (const { filePath, config } of configs) {
@@ -573,18 +682,71 @@ function mergePluginConfig(configs: { filePath: string; config: PluginConfig }[]
573
682
 
574
683
  return item.enable === true && typeof item.package === 'string' && item.package.length > 0
575
684
  })
576
- .sort(([left], [right]) => left.localeCompare(right))
577
- }
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
+ }
578
743
 
579
744
  function serializePluginOptions(options: unknown) {
580
745
  return options === undefined ? '' : `, options: ${JSON.stringify(options)}`
581
746
  }
582
747
 
583
- function generatePluginContextRegistry() {
584
- const enabledPlugins = mergePluginConfig([
585
- { filePath: join(packageDir, 'plugins/config.ts'), config: readPluginConfig(join(packageDir, 'plugins/config.ts')) },
586
- { filePath: join(srcDir, 'config/plugin.ts'), config: readPluginConfig(join(srcDir, 'config/plugin.ts')) },
587
- ])
748
+ function generatePluginContextRegistry() {
749
+ const enabledPlugins = getEnabledPluginConfigItems()
588
750
  const imports = enabledPlugins.map(([name, item]) => {
589
751
  return `import ${toIdentifier(['plugin', name])} from ${JSON.stringify(item.package)}`
590
752
  })
@@ -606,13 +768,14 @@ ${moduleEntries.join('\n')}
606
768
  writeGeneratedFile('plugins.generated.ts', content)
607
769
  }
608
770
 
609
- export function generateRegistry() {
610
- generateControllersRegistry()
611
- generateMiddlewaresRegistry()
612
- generateModelsRegistry()
613
- generateServicesRegistry()
614
- generatePluginContextRegistry()
615
- }
771
+ export function generateRegistry() {
772
+ generateControllersRegistry()
773
+ generateMiddlewaresRegistry()
774
+ generateModelsRegistry()
775
+ generateServicesRegistry()
776
+ generateCronsRegistry()
777
+ generatePluginContextRegistry()
778
+ }
616
779
 
617
780
  if (import.meta.main) {
618
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 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 ADDED
@@ -0,0 +1,150 @@
1
+ # Agent 维护说明
2
+
3
+ 这份文档给 Codex、Claude 或其他代码代理阅读,用来说明维护 `jax-hono` 时的默认规则。
4
+
5
+ ## 本地 link 规则
6
+
7
+ `jax-hono` 是用户自己维护的框架,常通过 `jax link` 链接到业务工程的 `node_modules/jax-hono`。
8
+
9
+ 如果在业务工程中需要修改 `jax-hono` 行为,先检查依赖是否已经 link 到本地源码目录。不要把它当成一次性的第三方 `node_modules` 临时补丁。
10
+
11
+ 在 Windows PowerShell 中,可以在业务仓库根目录查找所有 `jax-hono` 依赖:
12
+
13
+ ```powershell
14
+ Get-ChildItem -Path . -Directory -Recurse -Filter jax-hono |
15
+ Where-Object { $_.FullName -match '\\node_modules\\jax-hono$' } |
16
+ Format-List LinkType,Target,FullName
17
+ ```
18
+
19
+ 如果 `LinkType` 是 `Junction` 或 `SymbolicLink`,直接修改 `Target` 指向的 `jax-hono` 源码目录。
20
+
21
+ 如果没有 link,先执行:
22
+
23
+ ```powershell
24
+ jax link
25
+ ```
26
+
27
+ 然后再次确认 link 状态,再修改框架源码。
28
+
29
+ ## 修改生成器后的验证
30
+
31
+ 修改 `build/generate-registry.ts`、`build/generate-config.ts` 等生成逻辑后,需要在使用方工程里重新生成。
32
+
33
+ 常见命令:
34
+
35
+ ```powershell
36
+ bun run setup
37
+ ```
38
+
39
+ 然后检查业务工程中的生成结果,例如:
40
+
41
+ ```text
42
+ generated/controllers.generated.ts
43
+ generated/models.generated.ts
44
+ generated/services.generated.ts
45
+ generated/middlewares.generated.ts
46
+ generated/plugins.generated.ts
47
+ ```
48
+
49
+ 如果业务仓库里有多个工程都依赖 `jax-hono`,分别查看各工程的 `package.json`,运行它们自己的 setup/build 命令。
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
+
96
+ ## Controller 注册约定
97
+
98
+ 生成器需要保持不同命名空间的边界清晰:
99
+
100
+ - `*.backend.ts` 注册到 `controllers.backend`
101
+ - `*.api.ts` 注册到 `controllers.api`
102
+ - `*.open.ts` 注册到 `controllers.open`
103
+ - `*.client.ts` 注册到 `controllers.client`
104
+ - `*.controller.ts` 注册到 `controllers` 根对象
105
+
106
+ `.controller.ts` 表示通用 Controller,供 backend/api/open/client 等不同路由复用,不应自动归入 `controllers.backend`。
107
+
108
+ 例如:
109
+
110
+ ```text
111
+ src/modules/oss/oss.controller.ts
112
+ src/modules/oss/oss-bucket.controller.ts
113
+ ```
114
+
115
+ 应生成:
116
+
117
+ ```ts
118
+ controllers.oss
119
+ controllers.ossBucket
120
+ ```
121
+
122
+ 如果需要后台专用 Controller,使用:
123
+
124
+ ```text
125
+ src/modules/foo/foo.backend.ts
126
+ ```
127
+
128
+ 应生成:
129
+
130
+ ```ts
131
+ controllers.backend.foo
132
+ ```
133
+
134
+ ## 模块路径命名
135
+
136
+ 模块内文件名如果以模块目录名开头,生成 key 时可以去掉重复前缀:
137
+
138
+ ```text
139
+ src/modules/oss/oss.controller.ts -> controllers.oss
140
+ src/modules/oss/oss-bucket.controller.ts -> controllers.ossBucket
141
+ ```
142
+
143
+ 不要生成成:
144
+
145
+ ```ts
146
+ controllers.oss.oss
147
+ controllers.oss.ossBucket
148
+ ```
149
+
150
+ 这样业务侧调用会更自然,也更适合在不同路由层复用。
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.14",
3
+ "version": "1.0.16",
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
+ }