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.
@@ -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' },
@@ -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 entries = getFilesFromDirs(group.dirs)
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
- .filter((entry) => {
253
- if (group.name !== 'controller') {
254
- return true
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 = indentObject(
280
- buildNestedObject(entries.map((entry) => ({
281
- path: entry.modulePath.split('/').map(toCamelCase),
282
- value: `createController(${entry.importName})`,
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 = 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
- : ''
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 = 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
- ])
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
- generatePluginContextRegistry()
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 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.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
+ }