create-hazejs 0.0.0-stage → 0.1.2

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.
Files changed (187) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -2
  3. package/bin/create-hazejs.mjs +2 -0
  4. package/dist/index.d.mts +1 -0
  5. package/dist/index.mjs +74 -0
  6. package/dist/scaffold.d.mts +29 -0
  7. package/dist/scaffold.mjs +76 -0
  8. package/package.json +77 -3
  9. package/templates/api/.env +4 -0
  10. package/templates/api/.env.test +1 -0
  11. package/templates/api/README.md +383 -0
  12. package/templates/api/_gitignore +9 -0
  13. package/templates/api/app/controller/AuthController.ts +45 -0
  14. package/templates/api/app/controller/GreetingController.ts +23 -0
  15. package/templates/api/app/controller/UserController.ts +38 -0
  16. package/templates/api/app/event/UserRegistered.ts +6 -0
  17. package/templates/api/app/exception/Handler.ts +13 -0
  18. package/templates/api/app/job/SendWelcomeEmail.ts +19 -0
  19. package/templates/api/app/listener/BroadcastRegistration.ts +7 -0
  20. package/templates/api/app/listener/QueueWelcomeEmail.ts +6 -0
  21. package/templates/api/app/mail/WelcomeMail.ts +17 -0
  22. package/templates/api/app/model/User.ts +11 -0
  23. package/templates/api/app/policy/UserPolicy.ts +14 -0
  24. package/templates/api/app/provider/AppProvider.ts +9 -0
  25. package/templates/api/app/provider/DatabaseProvider.ts +4 -0
  26. package/templates/api/app/response/Formatter.ts +17 -0
  27. package/templates/api/app/service/GreetingService.ts +6 -0
  28. package/templates/api/app/validate/auth.ts +20 -0
  29. package/templates/api/app/validate/greeting.ts +5 -0
  30. package/templates/api/app/validate/user.ts +12 -0
  31. package/templates/api/config/app.ts +7 -0
  32. package/templates/api/config/auth.ts +23 -0
  33. package/templates/api/config/cache.ts +23 -0
  34. package/templates/api/config/cors.ts +11 -0
  35. package/templates/api/config/database.ts +17 -0
  36. package/templates/api/config/filesystem.ts +32 -0
  37. package/templates/api/config/mail.ts +31 -0
  38. package/templates/api/config/middleware.ts +10 -0
  39. package/templates/api/config/queue.ts +21 -0
  40. package/templates/api/config/throttle.ts +12 -0
  41. package/templates/api/config/websocket.ts +12 -0
  42. package/templates/api/database/migrations/0000_create_users.sql +9 -0
  43. package/templates/api/database/migrations/0001_add_user_password.sql +1 -0
  44. package/templates/api/database/migrations/0002_add_user_role.sql +1 -0
  45. package/templates/api/database/migrations/meta/0000_snapshot.json +71 -0
  46. package/templates/api/database/migrations/meta/0001_snapshot.json +78 -0
  47. package/templates/api/database/migrations/meta/0002_snapshot.json +86 -0
  48. package/templates/api/database/migrations/meta/_journal.json +27 -0
  49. package/templates/api/database/schema/users.ts +17 -0
  50. package/templates/api/database/seeders/01-users.ts +15 -0
  51. package/templates/api/haze.config.ts +5 -0
  52. package/templates/api/package.json +38 -0
  53. package/templates/api/route/api.ts +22 -0
  54. package/templates/api/route/ws.ts +18 -0
  55. package/templates/api/schedule/app.ts +11 -0
  56. package/templates/api/tests/auth.test.ts +77 -0
  57. package/templates/api/tests/greeting.test.ts +29 -0
  58. package/templates/api/tests/user.test.ts +58 -0
  59. package/templates/api/tsconfig.json +34 -0
  60. package/templates/api/vitest.config.ts +4 -0
  61. package/templates/react/.env +8 -0
  62. package/templates/react/.env.test +1 -0
  63. package/templates/react/README.md +374 -0
  64. package/templates/react/_gitignore +9 -0
  65. package/templates/react/haze.config.ts +8 -0
  66. package/templates/react/index.html +12 -0
  67. package/templates/react/package.json +46 -0
  68. package/templates/react/server/app/controller/AuthController.ts +45 -0
  69. package/templates/react/server/app/controller/GreetingController.ts +23 -0
  70. package/templates/react/server/app/controller/UserController.ts +38 -0
  71. package/templates/react/server/app/event/UserRegistered.ts +6 -0
  72. package/templates/react/server/app/exception/Handler.ts +13 -0
  73. package/templates/react/server/app/job/SendWelcomeEmail.ts +19 -0
  74. package/templates/react/server/app/listener/BroadcastRegistration.ts +7 -0
  75. package/templates/react/server/app/listener/QueueWelcomeEmail.ts +6 -0
  76. package/templates/react/server/app/mail/WelcomeMail.ts +17 -0
  77. package/templates/react/server/app/model/User.ts +11 -0
  78. package/templates/react/server/app/policy/UserPolicy.ts +14 -0
  79. package/templates/react/server/app/provider/AppProvider.ts +9 -0
  80. package/templates/react/server/app/provider/DatabaseProvider.ts +4 -0
  81. package/templates/react/server/app/provider/SocketProvider.ts +4 -0
  82. package/templates/react/server/app/response/Formatter.ts +17 -0
  83. package/templates/react/server/app/service/GreetingService.ts +6 -0
  84. package/templates/react/server/app/socket/chat.ts +17 -0
  85. package/templates/react/server/app/validate/auth.ts +20 -0
  86. package/templates/react/server/app/validate/greeting.ts +5 -0
  87. package/templates/react/server/app/validate/user.ts +12 -0
  88. package/templates/react/server/config/app.ts +7 -0
  89. package/templates/react/server/config/auth.ts +23 -0
  90. package/templates/react/server/config/cache.ts +23 -0
  91. package/templates/react/server/config/database.ts +17 -0
  92. package/templates/react/server/config/filesystem.ts +32 -0
  93. package/templates/react/server/config/mail.ts +31 -0
  94. package/templates/react/server/config/middleware.ts +11 -0
  95. package/templates/react/server/config/queue.ts +21 -0
  96. package/templates/react/server/config/socketio.ts +13 -0
  97. package/templates/react/server/config/throttle.ts +12 -0
  98. package/templates/react/server/config/websocket.ts +12 -0
  99. package/templates/react/server/database/migrations/0000_create_users.sql +9 -0
  100. package/templates/react/server/database/migrations/0001_add_user_password.sql +1 -0
  101. package/templates/react/server/database/migrations/0002_add_user_role.sql +1 -0
  102. package/templates/react/server/database/migrations/meta/0000_snapshot.json +71 -0
  103. package/templates/react/server/database/migrations/meta/0001_snapshot.json +78 -0
  104. package/templates/react/server/database/migrations/meta/0002_snapshot.json +86 -0
  105. package/templates/react/server/database/migrations/meta/_journal.json +27 -0
  106. package/templates/react/server/database/schema/users.ts +17 -0
  107. package/templates/react/server/database/seeders/01-users.ts +15 -0
  108. package/templates/react/server/route/api.ts +24 -0
  109. package/templates/react/server/route/ws.ts +18 -0
  110. package/templates/react/server/schedule/app.ts +11 -0
  111. package/templates/react/src/App.css +22 -0
  112. package/templates/react/src/App.tsx +72 -0
  113. package/templates/react/src/api.ts +48 -0
  114. package/templates/react/src/main.tsx +9 -0
  115. package/templates/react/src/socket.ts +5 -0
  116. package/templates/react/src/vite-env.d.ts +5 -0
  117. package/templates/react/tests/auth.test.ts +77 -0
  118. package/templates/react/tests/greeting.test.ts +29 -0
  119. package/templates/react/tests/user.test.ts +58 -0
  120. package/templates/react/tsconfig.app.json +22 -0
  121. package/templates/react/tsconfig.json +7 -0
  122. package/templates/react/tsconfig.server.json +34 -0
  123. package/templates/react/vite.config.ts +18 -0
  124. package/templates/react/vitest.config.ts +8 -0
  125. package/templates/vue/.env +8 -0
  126. package/templates/vue/.env.test +1 -0
  127. package/templates/vue/README.md +374 -0
  128. package/templates/vue/_gitignore +9 -0
  129. package/templates/vue/haze.config.ts +8 -0
  130. package/templates/vue/index.html +12 -0
  131. package/templates/vue/package.json +44 -0
  132. package/templates/vue/server/app/controller/AuthController.ts +45 -0
  133. package/templates/vue/server/app/controller/GreetingController.ts +23 -0
  134. package/templates/vue/server/app/controller/UserController.ts +38 -0
  135. package/templates/vue/server/app/event/UserRegistered.ts +6 -0
  136. package/templates/vue/server/app/exception/Handler.ts +13 -0
  137. package/templates/vue/server/app/job/SendWelcomeEmail.ts +19 -0
  138. package/templates/vue/server/app/listener/BroadcastRegistration.ts +7 -0
  139. package/templates/vue/server/app/listener/QueueWelcomeEmail.ts +6 -0
  140. package/templates/vue/server/app/mail/WelcomeMail.ts +17 -0
  141. package/templates/vue/server/app/model/User.ts +11 -0
  142. package/templates/vue/server/app/policy/UserPolicy.ts +14 -0
  143. package/templates/vue/server/app/provider/AppProvider.ts +9 -0
  144. package/templates/vue/server/app/provider/DatabaseProvider.ts +4 -0
  145. package/templates/vue/server/app/provider/SocketProvider.ts +4 -0
  146. package/templates/vue/server/app/response/Formatter.ts +17 -0
  147. package/templates/vue/server/app/service/GreetingService.ts +6 -0
  148. package/templates/vue/server/app/socket/chat.ts +17 -0
  149. package/templates/vue/server/app/validate/auth.ts +20 -0
  150. package/templates/vue/server/app/validate/greeting.ts +5 -0
  151. package/templates/vue/server/app/validate/user.ts +12 -0
  152. package/templates/vue/server/config/app.ts +7 -0
  153. package/templates/vue/server/config/auth.ts +23 -0
  154. package/templates/vue/server/config/cache.ts +23 -0
  155. package/templates/vue/server/config/database.ts +17 -0
  156. package/templates/vue/server/config/filesystem.ts +32 -0
  157. package/templates/vue/server/config/mail.ts +31 -0
  158. package/templates/vue/server/config/middleware.ts +11 -0
  159. package/templates/vue/server/config/queue.ts +21 -0
  160. package/templates/vue/server/config/socketio.ts +13 -0
  161. package/templates/vue/server/config/throttle.ts +12 -0
  162. package/templates/vue/server/config/websocket.ts +12 -0
  163. package/templates/vue/server/database/migrations/0000_create_users.sql +9 -0
  164. package/templates/vue/server/database/migrations/0001_add_user_password.sql +1 -0
  165. package/templates/vue/server/database/migrations/0002_add_user_role.sql +1 -0
  166. package/templates/vue/server/database/migrations/meta/0000_snapshot.json +71 -0
  167. package/templates/vue/server/database/migrations/meta/0001_snapshot.json +78 -0
  168. package/templates/vue/server/database/migrations/meta/0002_snapshot.json +86 -0
  169. package/templates/vue/server/database/migrations/meta/_journal.json +27 -0
  170. package/templates/vue/server/database/schema/users.ts +17 -0
  171. package/templates/vue/server/database/seeders/01-users.ts +15 -0
  172. package/templates/vue/server/route/api.ts +24 -0
  173. package/templates/vue/server/route/ws.ts +18 -0
  174. package/templates/vue/server/schedule/app.ts +11 -0
  175. package/templates/vue/src/App.vue +92 -0
  176. package/templates/vue/src/api.ts +48 -0
  177. package/templates/vue/src/env.d.ts +5 -0
  178. package/templates/vue/src/main.ts +4 -0
  179. package/templates/vue/src/socket.ts +5 -0
  180. package/templates/vue/tests/auth.test.ts +77 -0
  181. package/templates/vue/tests/greeting.test.ts +29 -0
  182. package/templates/vue/tests/user.test.ts +58 -0
  183. package/templates/vue/tsconfig.app.json +22 -0
  184. package/templates/vue/tsconfig.json +7 -0
  185. package/templates/vue/tsconfig.server.json +34 -0
  186. package/templates/vue/vite.config.ts +18 -0
  187. package/templates/vue/vitest.config.ts +8 -0
@@ -0,0 +1,11 @@
1
+ import { defineSchedule } from '@hazejs/core'
2
+
3
+ /**
4
+ * 定时任务:haze schedule:work(开发 / 常驻),或系统 cron 每分钟执行 haze schedule:run;
5
+ * 构建产物:node dist/…/worker.mjs schedule,Workers 为 Cron Triggers,Lambda 为 index.schedule。
6
+ */
7
+ export default defineSchedule((s) => {
8
+ s.call('heartbeat', () => {
9
+ // 示例:替换为清理过期数据、生成报表等;onOneServer 保证多实例时只执行一次
10
+ }).dailyAt('03:00').onOneServer()
11
+ })
@@ -0,0 +1,77 @@
1
+ import { Events, Mail, MailFake, Queue, Storage } from '@hazejs/core'
2
+ import { refreshDatabase } from '@hazejs/orm'
3
+ import { useTestApp } from '@hazejs/testing'
4
+ import { beforeEach, describe, expect, it } from 'vitest'
5
+ import UserRegistered from '~/app/event/UserRegistered'
6
+ import SendWelcomeEmail from '~/app/job/SendWelcomeEmail'
7
+ import WelcomeMail from '~/app/mail/WelcomeMail'
8
+ import { User } from '~/app/model/User'
9
+
10
+ const t = useTestApp()
11
+ let fake: ReturnType<typeof Queue.fake>
12
+ // 队列改为记录投递,断言后可用 fake.drain() 执行
13
+ beforeEach(() => {
14
+ fake = Queue.fake()
15
+ })
16
+ beforeEach(() => refreshDatabase({ migrations: 'database/migrations' }))
17
+
18
+ const ada = { name: 'Ada', email: 'ada@example.com', password: 'correct horse' }
19
+
20
+ describe('auth', () => {
21
+ it('registers, logs in and reads the current user with a Bearer token', async () => {
22
+ const registered = await t.post('/api/auth/register', ada)
23
+ expect(registered.status).toBe(201)
24
+ expect(registered.body.data.user).toMatchObject({ name: 'Ada', email: 'ada@example.com' })
25
+ // 密码哈希不会出现在响应中
26
+ expect(registered.body.data.user).not.toHaveProperty('password')
27
+ // 注册事件的监听器投递欢迎邮件任务(app/listener/QueueWelcomeEmail.ts)
28
+ fake.assertDispatched(SendWelcomeEmail, payload => payload.userId === registered.body.data.user.id)
29
+ // 执行任务:发送欢迎邮件(Mail.fake() 只记录不投递)
30
+ const mail = Mail.fake()
31
+ await fake.drain()
32
+ mail.assertSent(WelcomeMail, sent => MailFake.hasRecipient(sent, ada.email) && sent.message.subject === '欢迎加入,Ada')
33
+
34
+ const login = await t.post('/api/auth/login', { email: ada.email, password: ada.password })
35
+ expect(login.body.data).toMatchObject({ type: 'Bearer', expiresIn: 7200 })
36
+
37
+ const me = await t.withHeaders({ authorization: `Bearer ${login.body.data.token}` }).get('/api/auth/me')
38
+ expect(me.body.data).toMatchObject({ email: 'ada@example.com' })
39
+ })
40
+
41
+ it('emits UserRegistered on registration', async () => {
42
+ const events = Events.fake([UserRegistered])
43
+ const registered = await t.post('/api/auth/register', ada)
44
+ events.assertEmitted(UserRegistered, payload => payload.userId === registered.body.data.user.id && payload.email === ada.email)
45
+ // 事件被 fake 后监听器不执行,任务也不会投递
46
+ fake.assertNotDispatched(SendWelcomeEmail)
47
+ })
48
+
49
+ it('rejects wrong passwords and anonymous requests with 401', async () => {
50
+ await t.post('/api/auth/register', ada)
51
+ const wrong = await t.post('/api/auth/login', { email: ada.email, password: 'nope' })
52
+ expect([wrong.status, wrong.body.message]).toEqual([401, '邮箱或密码错误'])
53
+ expect((await t.get('/api/auth/me')).status).toBe(401)
54
+ })
55
+
56
+ it('actingAs() logs a user in without going through the login endpoint', async () => {
57
+ const user = await User.create({ name: 'Bob', email: 'bob@example.com' })
58
+ const me = await (await t.actingAs(user)).get('/api/auth/me')
59
+ expect(me.body.data).toMatchObject({ id: user.id, email: 'bob@example.com' })
60
+ })
61
+
62
+ it('uploads an avatar to the public disk', async () => {
63
+ const disk = Storage.fake('public')
64
+ const user = await User.create({ name: 'Bob', email: 'bob@example.com' })
65
+ const form = new FormData()
66
+ form.set('avatar', new File([
67
+ new Uint8Array([137, 80, 78, 71]),
68
+ ], 'me.png', { type: 'image/png' }))
69
+ const res = await (await t.actingAs(user)).post('/api/auth/avatar', form)
70
+ expect(res.status).toBe(201)
71
+ expect(res.body.data.url).toBe(`/storage/${res.body.data.path}`)
72
+ await disk.assertExists(res.body.data.path)
73
+
74
+ form.set('avatar', new File(['text'], 'a.txt', { type: 'text/plain' }))
75
+ expect((await (await t.actingAs(user)).post('/api/auth/avatar', form)).status).toBe(422)
76
+ })
77
+ })
@@ -0,0 +1,29 @@
1
+ import { useTestApp } from '@hazejs/testing'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ const t = useTestApp()
5
+
6
+ describe('greeting', () => {
7
+ it('says hello', async () => {
8
+ const res = await t.get('/api/hello')
9
+ expect(res.status).toBe(200)
10
+ expect(res.body).toEqual({
11
+ code: 0,
12
+ message: 'ok',
13
+ data: { app: '{{name}}', message: 'Hello, hazejs!' },
14
+ })
15
+ })
16
+
17
+ it('greets by name', async () => {
18
+ const res = await t.post('/api/hello', { name: ' Ada ' })
19
+ expect(res.body.data).toEqual({ message: 'Hello, Ada!' })
20
+ })
21
+
22
+ it('validates the request body', async () => {
23
+ const res = await t.post('/api/hello', { name: '' })
24
+ expect(res.status).toBe(422)
25
+ expect(res.body.errors).toEqual([
26
+ { target: 'body', path: 'name', message: 'name 不能为空' },
27
+ ])
28
+ })
29
+ })
@@ -0,0 +1,58 @@
1
+ import { refreshDatabase } from '@hazejs/orm'
2
+ import { useTestApp } from '@hazejs/testing'
3
+ import { beforeEach, describe, expect, it } from 'vitest'
4
+ import { User } from '~/app/model/User'
5
+
6
+ const t = useTestApp()
7
+ // .env.test 中 DATABASE_URL=:memory:,每个用例都是执行过迁移的全新数据库
8
+ beforeEach(() => refreshDatabase({ migrations: 'database/migrations' }))
9
+
10
+ describe('users', () => {
11
+ it('creates, lists, updates and deletes users', async () => {
12
+ const created = await t.post('/api/users', { name: ' Ada ', email: 'ada@example.com' })
13
+ expect(created.status).toBe(201)
14
+ const ada = created.body.data
15
+ expect(ada).toMatchObject({ name: 'Ada', email: 'ada@example.com' })
16
+
17
+ const list = await t.get('/api/users')
18
+ expect(list.body.data).toMatchObject({
19
+ total: 1,
20
+ data: [
21
+ { id: ada.id },
22
+ ],
23
+ })
24
+
25
+ // 修改 / 删除需要登录且为本人(app/policy/UserPolicy.ts)
26
+ const asAda = await t.actingAs(await User.findOrFail(ada.id))
27
+ const updated = await asAda.patch(`/api/users/${ada.id}`, { name: 'Ada Lovelace' })
28
+ expect(updated.body.data.name).toBe('Ada Lovelace')
29
+
30
+ expect((await asAda.delete(`/api/users/${ada.id}`)).status).toBe(204)
31
+ expect((await t.get(`/api/users/${ada.id}`)).status).toBe(404)
32
+ })
33
+
34
+ it('only lets users change themselves, unless they are admins', async () => {
35
+ const ada = await User.create({ name: 'Ada', email: 'ada@example.com' })
36
+ const bob = await User.create({ name: 'Bob', email: 'bob@example.com' })
37
+ const admin = await User.create({ name: 'Admin', email: 'admin@example.com', role: 'admin' })
38
+
39
+ expect((await t.patch(`/api/users/${ada.id}`, { name: 'x' })).status).toBe(401)
40
+ const forbidden = await (await t.actingAs(bob)).patch(`/api/users/${ada.id}`, { name: 'x' })
41
+ expect([forbidden.status, forbidden.body.message]).toEqual([403, '只能修改自己的资料'])
42
+ expect((await (await t.actingAs(bob)).delete(`/api/users/${ada.id}`)).status).toBe(403)
43
+ expect((await (await t.actingAs(admin)).delete(`/api/users/${ada.id}`)).status).toBe(204)
44
+ })
45
+
46
+ it('rejects duplicate emails with 409', async () => {
47
+ await t.post('/api/users', { name: 'Ada', email: 'ada@example.com' })
48
+ const res = await t.post('/api/users', { name: 'Copy', email: 'ada@example.com' })
49
+ expect(res.status).toBe(409)
50
+ expect(res.body.message).toBe('数据已存在(违反唯一约束)')
51
+ })
52
+
53
+ it('validates input', async () => {
54
+ const res = await t.post('/api/users', { name: '', email: 'nope' })
55
+ expect(res.status).toBe(422)
56
+ expect(res.body.errors.map((e: { path: string }) => e.path)).toEqual(['name', 'email'])
57
+ })
58
+ })
@@ -0,0 +1,34 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "lib": [
5
+ "ES2023"
6
+ ],
7
+ "moduleDetection": "force",
8
+ "module": "ESNext",
9
+ "moduleResolution": "Bundler",
10
+ "paths": {
11
+ "~/*": [
12
+ "./*"
13
+ ]
14
+ },
15
+ "types": [
16
+ "node",
17
+ "vite/client",
18
+ "@hazejs/vite/client"
19
+ ],
20
+ "strict": true,
21
+ "noEmit": true,
22
+ "isolatedModules": true,
23
+ "verbatimModuleSyntax": true,
24
+ "skipLibCheck": true
25
+ },
26
+ "include": [
27
+ "**/*.ts",
28
+ ".haze/types/**/*.d.ts"
29
+ ],
30
+ "exclude": [
31
+ "node_modules",
32
+ "dist"
33
+ ]
34
+ }
@@ -0,0 +1,4 @@
1
+ import { defineHazeTestConfig } from '@hazejs/testing/config'
2
+ import { defineConfig } from 'vitest/config'
3
+
4
+ export default defineConfig(defineHazeTestConfig())
@@ -0,0 +1,8 @@
1
+ # 后端读取(process.env)
2
+ APP_NAME={{name}}
3
+ DATABASE_URL=file:./data/app.db
4
+
5
+ # 前端读取(import.meta.env,需 VITE_ 前缀)
6
+ VITE_APP_TITLE={{name}}
7
+ # 应用密钥(签名 Cookie、加密 Session、JWT),泄露后用 haze key:generate --force 更换
8
+ APP_KEY={{appKey}}
@@ -0,0 +1 @@
1
+ DATABASE_URL=:memory:
@@ -0,0 +1,374 @@
1
+ # {{name}}
2
+
3
+ React 19 + hazejs 全栈项目:前后端同一个仓库、同一个端口、一次构建。
4
+
5
+ ## 命令
6
+
7
+ ```bash
8
+ pnpm dev # Vite 开发服务器:前端 HMR,/api/** 由后端处理(改后端代码即时生效)
9
+ pnpm routes # 查看后端路由表
10
+ pnpm typegen # 生成配置键 / 路由名类型(dev 时自动执行)
11
+ pnpm test # 后端测试(@hazejs/testing)
12
+ pnpm typecheck # tsc 类型检查
13
+ pnpm build # 构建 dist/client 与 dist/server
14
+ pnpm start # 运行构建产物(同一进程托管前端页面与 API)
15
+ pnpm preview # 按构建时的部署预设本地运行产物(等同 vite preview)
16
+ ```
17
+
18
+ ## 代码生成
19
+
20
+ ```bash
21
+ npx haze make:controller User --resource # server/app/controller/UserController.ts
22
+ npx haze make:service User
23
+ npx haze make:validate User
24
+ npx haze make:test user
25
+ ```
26
+
27
+ 开发时浏览器直接访问出错的 `/api/*` 地址,会看到带源码定位的错误页;前端 `fetch` 仍得到 JSON。
28
+
29
+ ## 响应格式
30
+
31
+ - `server/app/response/Formatter.ts`:全局成功 / 失败响应结构(默认 `{ code, message, data }`)
32
+ - `server/app/interceptor/*.ts`:出站拦截器,拿到最终 Response 后可改头或替换(`haze make:interceptor`)
33
+ - 单个路由跳过:`.meta({ envelope: false })` 或 `return raw(value)`
34
+
35
+ ## 数据库
36
+
37
+ 默认使用 SQLite(`server/config/database.ts`,文件位于 `data/app.db`),表结构在 `server/database/schema/*.ts`(drizzle),模型在 `server/app/model/`:
38
+
39
+ ```bash
40
+ pnpm db:migrate # 执行迁移(首次运行前执行)
41
+ pnpm db:seed # 数据填充(server/database/seeders)
42
+ npx haze make:model Post # 生成模型 + 表结构
43
+ pnpm db:generate --name create_posts # 表结构变化后生成迁移
44
+ ```
45
+
46
+ ```ts
47
+ const user = await User.findOrFail(1) // 404:User 1 不存在
48
+ await User.where('name', 'like', '%a%').order('id', 'desc').paginate(1, 20)
49
+ await Db.table(users).where('id', 1).update({ name: 'Ada' })
50
+ await Db.transaction(async () => { /* 内部的 Db / Model 操作自动加入事务 */ })
51
+ ```
52
+
53
+ 切换到 PostgreSQL / MySQL:安装 `postgres` 或 `mysql2`,修改 `driver` 与 `url`,表结构改用 `drizzle-orm/pg-core` / `mysql-core`。
54
+
55
+ MongoDB:安装 `mongodb`,连接配置 `{ driver: 'mongodb', url: 'mongodb://…/app' }`,模型使用 `@hazejs/orm/mongo` 的 `MongoModel`(`npx haze make:model Article --dialect mongodb`),查询 API 与 SQL 一致。
56
+
57
+ ## 多应用
58
+
59
+ 在 `server/config/app.ts` 中声明应用,代码放在 `server/app/<name>/`(controller、route、config、middleware 配置、exception/Handler.ts、response/Formatter.ts):
60
+
61
+ ```ts
62
+ export default defineConfig(() => ({
63
+ name: env('APP_NAME', 'my-app'),
64
+ apps: {
65
+ admin: { prefix: '/admin' }, // /admin/** → server/app/admin
66
+ api: { domain: 'api.example.com' }, // 按域名分发,支持 *.example.com
67
+ },
68
+ }))
69
+ ```
70
+
71
+ - 应用配置 `server/app/<name>/config/*.ts` 与全局配置深合并,请求内 `config()`、`ctx.config` 读取应用配置
72
+ - 路由名自动带应用前缀:`router.url('admin.users.index')`
73
+ - 生成代码:`npx haze make:controller User --app admin`
74
+
75
+ ## 认证与安全
76
+
77
+ 模板自带 JWT 登录示例(`server/config/auth.ts`):
78
+
79
+ ```bash
80
+ curl -X POST localhost:5173/api/auth/register -H 'content-type: application/json' \
81
+ -d '{"name":"Ada","email":"ada@example.com","password":"correct horse"}'
82
+ curl -X POST localhost:5173/api/auth/login -H 'content-type: application/json' \
83
+ -d '{"email":"ada@example.com","password":"correct horse"}' # → { token, expiresIn, ... }
84
+ curl localhost:5173/api/auth/me -H 'authorization: Bearer <token>'
85
+ ```
86
+
87
+ - `APP_KEY`(`.env`,创建项目时已生成)用于签名 Cookie、加密 Session 与 JWT;更换:`npx haze key:generate --force`
88
+ - 路由保护:`.middleware('auth')`、`'auth:web,api'`(任一 Guard 通过)、`'guest'`;代码中 `await ctx.auth.user()`、`ctx.auth.jwt().login(user)`
89
+ - 浏览器同源场景可加 session Guard:`web: { driver: 'session', provider: 'users' }`,`ctx.auth.session('web').attempt(credentials)`
90
+ - 测试:`await t.actingAs(user)` 返回已登录的客户端
91
+ - 密码:`Hash.make(password)` / `Hash.verify(hash, password)`(scrypt)
92
+
93
+ 授权(`server/app/policy/*.ts` 自动注册,`npx haze make:policy Post` 生成):
94
+
95
+ ```ts
96
+ export default definePolicy(User, {
97
+ before: (actor: User | undefined) => actor?.role === 'admin' || undefined, // 管理员全部放行
98
+ update: (actor: User, user: User) => actor.id === user.id || deny('只能修改自己的资料'),
99
+ })
100
+ ```
101
+
102
+ - 控制器:`await ctx.authorize('update', user)`(未登录 401,无权限 403),`await ctx.can('delete', user)` 返回 boolean
103
+ - 路由:`.middleware('can:admin.access')`、`'can:create,Post'`、`'can:update,Post,id'`(按路由参数加载模型,实例在 `ctx.get('post')`)
104
+ - 能力名由 typegen 生成类型,拼错会在类型检查时报错;与模型无关的能力用 `defineAbilities({ 'admin.access': user => ... })`
105
+ - CSRF:全局启用了 `csrf` 中间件(Laravel 方式的同步令牌)。后端下发 `XSRF-TOKEN` Cookie,`src/api.ts` 会自动在修改类请求中带上 `X-XSRF-TOKEN`;带 `Authorization: Bearer` 的请求与测试环境不校验
106
+
107
+ 内置中间件(`server/config/middleware.ts` 中按名使用,配置文件放在 `server/config/` 下同名文件):
108
+
109
+ | 名字 | 用途 | 配置 |
110
+ |------|------|------|
111
+ | `cors` | 跨域,需放在 global | `cors.ts`(`defineCorsConfig`) |
112
+ | `secureHeaders` | nosniff、frame-options、referrer-policy 等安全头,生产 https 下加 HSTS | `secureHeaders.ts` |
113
+ | `compress` | br / gzip / deflate 压缩响应 | `compress.ts` |
114
+ | `throttle:60,60` / `throttle:api` | 限流(默认按用户或 IP),返回 RateLimit-* 头与 429 | `throttle.ts`(命名规则、共享存储) |
115
+ | `bodyLimit:10mb` | 调整单个路由的请求体上限(默认 `app.bodyLimit`,1mb) | — |
116
+ | `session` | 启动会话:`ctx.session.get / set / flash / regenerate` | `session.ts`(cookie / memory / 自定义存储) |
117
+ | `auth` / `guest` | 认证 | `auth.ts` |
118
+ | `can:<ability>[,Model[,param]]` | 授权 | `app/policy/*.ts` |
119
+ | `csrf` | CSRF 同步令牌(419) | `csrf.ts`(except、cookie、testing) |
120
+ | `cacheResponse:60[,user]` | 缓存 GET 响应 | `cache.ts`(responseStore) |
121
+
122
+ 文件上传:multipart 请求的 `await ctx.body()` 中文件字段为 `UploadedFile`(继承 File,可直接用 valibot 的 `v.file()`、`v.maxSize()`、`v.mimeType()` 校验),`await (await ctx.file('avatar'))?.store('avatars', 'public')` 保存到存储磁盘(见下方“文件存储与邮件”)。
123
+
124
+ ## 缓存
125
+
126
+ `server/config/cache.ts`(默认内存;设置 `CACHE_STORE=redis` 与 `REDIS_URL` 切换到 Redis,需要 `pnpm add ioredis`):
127
+
128
+ ```ts
129
+ import { Cache } from '@hazejs/core'
130
+
131
+ const stats = await Cache.remember('stats', '10m', () => computeStats())
132
+ await Cache.increment('views') // 原子自增
133
+ await Cache.lock('report', '30s').run(() => generateReport()) // 原子锁(拿不到时抛 LockTimeoutException)
134
+ await Cache.store('redis').get('key') // 指定存储
135
+ ```
136
+
137
+ | 驱动 | 适用 | 说明 |
138
+ |------|------|------|
139
+ | `memory` | 开发、单实例 | 进程内 |
140
+ | `redis` | 多实例 Node / Docker / Lambda | ioredis,原子自增与锁 |
141
+ | `upstash` | Vercel / Netlify / Workers | HTTP,需要 `@upstash/redis` |
142
+ | `kv` | Cloudflare Workers | 最终一致、非原子:适合缓存与 Session,不支持锁 |
143
+
144
+ - 多实例共享:`server/config/throttle.ts` 的 `store` 与 Session 的 `{ driver: 'cache', store: 'redis' }` 使用同一个缓存存储
145
+ - Workers 上的限流:限流规则配置 `binding: 'API_RATE_LIMITER'`,使用 Cloudflare Rate Limiting 绑定(原子、全局)
146
+ - 响应缓存:`.middleware('cacheResponse:60')` 缓存 GET 响应(`X-Cache: HIT / MISS`),`cacheResponse:60,user` 按用户区分
147
+
148
+ ## 队列与定时任务
149
+
150
+ 任务放在 `server/app/job/*.ts`(`npx haze make:job SendInvoice` 生成),配置在 `server/config/queue.ts`:
151
+
152
+ ```ts
153
+ await SendWelcomeEmail.dispatch({ userId: 1 }) // 默认 memory:在本进程消费
154
+ await SendWelcomeEmail.dispatch({ userId: 1 }, { delay: '10m' }) // 延迟
155
+ ```
156
+
157
+ - 重试:`defineJob({ tries: 3, backoff: [10, 60], timeout: '30s', failed(payload, error) { ... } })`
158
+ - 多实例:设置 `QUEUE_CONNECTION=redis`(需要 `pnpm add bullmq ioredis`),单独运行消费进程 `pnpm queue`(`haze queue:work`,构建后为 `node dist/server/worker.mjs queue`)
159
+ - 失败任务:`haze queue:failed` / `queue:retry [id]` / `queue:forget <id>` / `queue:flush`
160
+ - 测试:`const fake = Queue.fake()` 后 `fake.assertDispatched(SendWelcomeEmail, p => p.userId === 1)`
161
+
162
+ 定时任务在 `server/schedule/*.ts`:
163
+
164
+ ```ts
165
+ export default defineSchedule((s) => {
166
+ s.call('report', () => sendReport()).weekdays().at('09:00').timezone('Asia/Shanghai')
167
+ s.job(PruneTokens, { days: 30 }).daily().onOneServer() // 多实例时只执行一次
168
+ s.cron('*/5 * * * *', 'sync', () => sync()).withoutOverlapping() // 上次未结束则跳过
169
+ })
170
+ ```
171
+
172
+ | 环境 | 运行方式 |
173
+ |------|----------|
174
+ | 开发 | `pnpm schedule`(`haze schedule:work`),`haze schedule:list` 查看下次执行时间 |
175
+ | node / bun / deno / docker | `node dist/server/worker.mjs schedule`(常驻),或系统 cron 每分钟执行 `node dist/server/worker.mjs schedule:run` |
176
+ | cloudflare | 自动生成 Cron Triggers(每分钟);队列用 Cloudflare Queues(`driver: 'cloudflare'`,Worker 导出 queue()) |
177
+ | aws-lambda | 同一份产物,handler 设为 `index.schedule`,EventBridge `rate(1 minute)` 触发 |
178
+ | vercel | 自动配置 Vercel Cron 调用 `/__haze/schedule`(需要设置 `CRON_SECRET`;Hobby 计划只允许每天一次,用 `haze.config.ts` 的 `vercel.cron` 调整) |
179
+
180
+ ## 文件存储与邮件
181
+
182
+ 磁盘配置在 `server/config/filesystem.ts`(默认 `local` 与 `public`;`s3` 需要 `pnpm add @aws-sdk/client-s3 @aws-sdk/lib-storage @aws-sdk/s3-request-presigner`,Workers 上用 `r2` 绑定):
183
+
184
+ ```ts
185
+ import { Storage } from '@hazejs/core'
186
+
187
+ const path = await file.store('avatars', 'public') // 上传文件,随机文件名
188
+ Storage.disk('public').url(path) // /storage/avatars/xxx.png(public 磁盘由框架直接提供)
189
+ await Storage.put('reports/2026.csv', csv)
190
+ await Storage.disk('s3').temporaryUrl('reports/2026.csv', '10m') // S3 预签名;local / r2 为 APP_KEY 签名的链接
191
+ return Storage.download('reports/2026.csv', '报表.csv') // 控制器中直接返回文件响应
192
+ ```
193
+
194
+ 邮件定义在 `server/app/mail/*.ts`,配置在 `server/config/mail.ts`(默认 `log`,只写日志):
195
+
196
+ ```ts
197
+ export default defineMail((payload: { name: string }) => ({
198
+ subject: `欢迎,${payload.name}`,
199
+ html: mailLayout({ body: html`<p>你好,${payload.name}</p>${button('开始使用', url)}` }), // 插值自动转义,纯文本自动生成
200
+ }))
201
+
202
+ await Mail.to(user.email).send(WelcomeMail, { name: user.name })
203
+ await Mail.to(user.email).queue(WelcomeMail, { name: user.name }) // 排队(任务 mail:send,失败重试)
204
+ await Mail.to(user.email).later('10m', WelcomeMail, { name: user.name })
205
+ ```
206
+
207
+ | 驱动 | 说明 |
208
+ |------|------|
209
+ | `log` / `array` | 只写日志 / 保存在内存中 |
210
+ | `smtp` | nodemailer(`pnpm add nodemailer`),不支持 Workers |
211
+ | `resend` / `postmark` / `mailgun` | HTTP 接口,可在 Workers 上运行 |
212
+ | `ses` | Amazon SES(`pnpm add @aws-sdk/client-sesv2`) |
213
+
214
+ - 本地查看邮件:运行 [Mailpit](https://mailpit.axllent.org)(`docker run -p 1025:1025 -p 8025:8025 axllent/mailpit`),设置 `MAIL_MAILER=smtp`(并 `pnpm add nodemailer`),打开 http://localhost:8025
215
+ - 附件:`attachments: [{ filename: 'a.pdf', content: bytes }]`,或 `{ filename, disk: 's3', path }` 发送时从磁盘读取
216
+ - 测试:`const mail = Mail.fake()` 后 `mail.assertSent(WelcomeMail, m => MailFake.hasRecipient(m, 'ada@example.com'))`;`const disk = Storage.fake('public')` 后 `await disk.assertExists(path)`
217
+
218
+ ## 事件与 WebSocket
219
+
220
+ 事件放在 `server/app/event/*.ts`,监听器在 `server/app/listener/*.ts`(`npx haze make:event OrderPaid` / `make:listener SendReceipt`),都会自动注册:
221
+
222
+ ```ts
223
+ export default defineEvent<{ userId: number, email: string }>() // app/event/UserRegistered.ts
224
+ export default defineListener(UserRegistered, ({ userId }) => SendWelcomeEmail.dispatch({ userId }))
225
+ export default defineListener(UserRegistered, handler, {
226
+ queue: { tries: 3 }
227
+ }) // 排队异步执行
228
+ await UserRegistered.emit({ userId: user.id, email: user.email })
229
+ ```
230
+
231
+ - 同步监听器按文件名顺序执行,返回 `false` 时不再执行后续监听器;排队监听器作为队列任务执行(payload 以 JSON 传递)
232
+ - 测试:`const events = Events.fake([UserRegistered])` 后 `events.assertEmitted(UserRegistered, p => p.userId === 1)`
233
+
234
+ WebSocket 在路由中定义(示例:`server/route/ws.ts`,`ws://<host>/api/ws`):
235
+
236
+ ```ts
237
+ r.ws('/api/chat', {
238
+ open(peer, ctx) { peer.subscribe('chat') },
239
+ message(peer, message) { peer.publish('chat', message.text()) },
240
+ }).middleware('auth') // 升级请求同样经过中间件
241
+
242
+ Socket.publish('users', { type: 'user.registered' }) // 在接口、任务、监听器中推送
243
+ ```
244
+
245
+ - 开发:`pnpm dev` 中直接可用,修改后新连接使用新代码;产物:node / bun / deno / docker 直接支持,`pnpm preview` 也可连接
246
+ - 多实例:设置 `WS_BROADCAST=redis`(`server/config/websocket.ts`),广播经 Redis 到达所有实例
247
+ - Cloudflare:自动生成 Durable Object(`HazeWebSocket`),所有连接在同一个 DO 中,`Socket.publish` 经 RPC 转发
248
+ - Vercel / Netlify / AWS Lambda:平台不支持 WebSocket(升级请求返回 426)
249
+
250
+ ## Socket.IO
251
+
252
+ 前端大多使用 socket.io-client,模板已集成官方 socket.io 服务端(`@hazejs/socket.io`),与 HTTP 共用端口(`/socket.io/`):
253
+
254
+ ```ts
255
+ // server/app/socket/chat.ts —— 命名空间 /chat(文件名决定,index.ts 为 /)
256
+ export default defineNamespace({
257
+ middleware: [
258
+ authenticate()
259
+ ], // 可选:用 config/auth.ts 的 Guard 鉴权,用户在 socket.data.user
260
+ connection(socket, io, chat) {
261
+ socket.on('chat:send', (text, ack) => {
262
+ chat.emit('chat:message', text)
263
+ ack?.('ok')
264
+ })
265
+ },
266
+ })
267
+
268
+ // src/socket.ts —— 前端
269
+ export const chat: Socket = io('/chat', { autoConnect: false }) // 需要登录:io('/chat', { auth: { token } })
270
+
271
+ // 在接口、任务、事件监听器中推送
272
+ SocketIO.of('/chat').emit('chat:message', 'hi')
273
+ SocketIO.to(`user:${id}`).emit('notice', data)
274
+ ```
275
+
276
+ - 开发:`pnpm dev` 中直接可用(长轮询与 WebSocket 都经 Vite 开发服务器),修改命名空间后新连接使用新代码
277
+ - 产物:node / docker 与 bun(官方 `@socket.io/bun-engine`)支持;`pnpm preview` 也可连接
278
+ - 多实例:设置 `SOCKET_ADAPTER=redis`(`server/config/socketio.ts`,需要 `pnpm add @socket.io/redis-adapter ioredis`)
279
+ - 不支持:Deno、Cloudflare Workers(请用 `r.ws()`)、Vercel / Netlify / AWS Lambda(无长连接)
280
+
281
+ ## 部署
282
+
283
+ ```bash
284
+ npx haze build --preset node # 默认:node dist/server/index.mjs(依赖已打包或追踪进产物,可整体拷走)
285
+ npx haze build --preset docker # dist/ 即构建上下文:docker build -t app dist
286
+ npx haze build --preset bun # bun dist/server/index.mjs
287
+ npx haze build --preset deno # deno run -A dist/server/index.mjs
288
+ npx haze build --preset vercel # .vercel/output → vercel deploy --prebuilt
289
+ npx haze build --preset netlify # .netlify/v1/functions + netlify.toml
290
+ npx haze build --preset aws-lambda # 打包 dist/lambda,handler 为 index.handler
291
+ npx haze build --preset cloudflare # dist/wrangler.json → wrangler deploy -c dist/wrangler.json
292
+ ```
293
+
294
+ 也可在 `haze.config.ts` 中设置 `preset`。构建会自动带上项目安装的数据库驱动。
295
+
296
+ 依赖处理与 Nitro 一致:能打包的依赖都打进产物,数据库驱动、原生模块等保持 external,并按实际引用的文件追踪复制到产物的 `node_modules`(node / bun / deno / aws-lambda / vercel)。可在 `haze.config.ts` 中调整:
297
+
298
+ ```ts
299
+ export default defineHazeConfig({
300
+ externals: {
301
+ traceDeps: ['sharp', 'some-pkg*'], // 保持 external 并追踪(* 表示整包复制)
302
+ inline: ['postgres'], // 强制打包
303
+ // trace: false, // node / bun 退回依赖项目的 node_modules
304
+ },
305
+ })
306
+ ```
307
+
308
+ 在 macOS / Windows 上构建 aws-lambda、vercel 产物时,如果追踪到了原生二进制,构建会给出警告:请在 Linux(CI / Docker)上构建。
309
+
310
+ `pnpm preview`(`haze preview`,全栈项目也可直接 `vite preview`)按构建时的预设运行产物本身:node / bun / deno / docker 启动产物进程,aws-lambda / vercel / netlify 在进程内调用函数,cloudflare 用 Miniflare 运行 Worker(D1 迁移自动应用到本地预览库)。
311
+
312
+ ### 生产迁移
313
+
314
+ 迁移文件打包进产物,生产环境不需要源码与 drizzle-kit:
315
+
316
+ | 预设 | 执行迁移 |
317
+ |------|----------|
318
+ | node / bun / deno | `node dist/server/migrate.mjs`(`--connection <name>` / `--all`) |
319
+ | docker | `docker run --rm <镜像> node server/migrate.mjs` |
320
+ | aws-lambda | 同一份产物,handler 设为 `index.migrate` 调用一次(事件 `{ "connection"?, "all"? }`) |
321
+ | vercel / netlify | 在构建命令前执行 `npx haze db:migrate`(使用生产库的 `DATABASE_URL`) |
322
+ | cloudflare(D1) | `npx wrangler d1 migrations apply <库名> --remote -c dist/wrangler.json` |
323
+ | cloudflare(Turso) | 在 CI 中执行 `npx haze db:migrate` |
324
+
325
+ ### Cloudflare:D1 / Turso
326
+
327
+ Workers 上可用 `d1`(绑定)与 `turso`(纯 HTTP 的 libsql 客户端)两种驱动,二者与 `sqlite` 共用同一套表结构与迁移:
328
+
329
+ ```ts
330
+ // config/database.ts —— 本地用 SQLite 文件,部署到 Workers 时切换为 D1
331
+ export default defineDatabaseConfig(() => ({
332
+ default: 'main',
333
+ connections: {
334
+ main: env('DB_DRIVER', 'sqlite') === 'd1'
335
+ ? { driver: 'd1', binding: 'DB' }
336
+ : {
337
+ driver: 'sqlite',
338
+ url: env('DATABASE_URL', 'file:./data/app.db')
339
+ },
340
+ // Turso:{ driver: 'turso', url: env('DATABASE_URL'), authToken: env('DATABASE_AUTH_TOKEN') }
341
+ },
342
+ }))
343
+ ```
344
+
345
+ ```ts
346
+ // haze.config.ts —— 合并进 dist/wrangler.json;D1 默认使用产物中的 migrations/
347
+ export default defineHazeConfig({
348
+ cloudflare: {
349
+ wrangler: {
350
+ vars: { DB_DRIVER: 'd1' },
351
+ d1_databases: [
352
+ { binding: 'DB', database_name: 'my-db', database_id: '<id>' }
353
+ ],
354
+ },
355
+ },
356
+ })
357
+ ```
358
+
359
+ D1 不支持交互式事务(`Db.transaction()` 会报错并提示),原子写入请用 `(await Db.drizzle()).batch([...])`。
360
+
361
+ ## 目录
362
+
363
+ ```
364
+ src/ 前端(React)
365
+ server/ 后端(hazejs)
366
+ app/ controller / service / validate / middleware / exception / provider / job / mail …
367
+ config/ 配置
368
+ route/ 路由(/api 前缀)
369
+ tests/ 后端测试
370
+ haze.config.ts 框架配置(srcDir、端口等)
371
+ vite.config.ts Vite 配置(haze() 插件)
372
+ ```
373
+
374
+ `.env` 中 `VITE_` 开头的变量暴露给前端,其余仅后端可见。
@@ -0,0 +1,9 @@
1
+ node_modules
2
+ dist
3
+ coverage
4
+ .env.local
5
+ .env.*.local
6
+ *.log
7
+ .haze
8
+ data
9
+ storage
@@ -0,0 +1,8 @@
1
+ import { defineHazeConfig } from '@hazejs/vite'
2
+
3
+ export default defineHazeConfig({
4
+ // 后端源码目录(config/ route/ app/ 位于其中)
5
+ srcDir: 'server',
6
+ // 生产环境端口(开发时使用 Vite 的端口)
7
+ port: 3000,
8
+ })
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>{{name}}</title>
7
+ </head>
8
+ <body>
9
+ <div id="app"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>