lakutata 3.0.0 → 3.1.0

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 (117) hide show
  1. package/README.md +50 -30
  2. package/README.zh-CN.md +130 -0
  3. package/dist/cjs/exports/QueueComponent.d.ts +1 -0
  4. package/dist/cjs/exports/QueueComponent.js +20 -0
  5. package/dist/esm/exports/QueueComponent.js +1 -0
  6. package/dist/types/exports/QueueComponent.d.ts +1 -0
  7. package/doc/en/Application.md +107 -0
  8. package/doc/en/CLI.md +274 -0
  9. package/doc/en/Cache.md +379 -0
  10. package/doc/en/Command.md +110 -0
  11. package/doc/en/ContainerdIntegration.md +17 -16
  12. package/doc/en/Controllers.md +256 -0
  13. package/doc/en/DBus.md +291 -0
  14. package/doc/en/DTOValidation.md +72 -43
  15. package/doc/en/Database.md +188 -0
  16. package/doc/en/DatabaseDrivers.md +287 -0
  17. package/doc/en/Decorator.md +52 -40
  18. package/doc/en/DockerIntegration.md +7 -6
  19. package/doc/en/Entities.md +240 -0
  20. package/doc/en/Entrypoint.md +216 -0
  21. package/doc/en/Exceptions.md +174 -0
  22. package/doc/en/GRPC.md +251 -0
  23. package/doc/en/GettingStarted.md +165 -0
  24. package/doc/en/HTTP.md +507 -0
  25. package/doc/en/HelperFunctions.md +11 -11
  26. package/doc/en/I18n.md +119 -115
  27. package/doc/en/Injection.md +144 -0
  28. package/doc/en/Installers.md +280 -0
  29. package/doc/en/Logger.md +206 -0
  30. package/doc/en/MCP.md +293 -0
  31. package/doc/en/Middlewares.md +213 -0
  32. package/doc/en/Migrations.md +202 -0
  33. package/doc/en/MongoEmbedded.md +149 -0
  34. package/doc/en/Monitor.md +164 -0
  35. package/doc/en/Mutex.md +27 -25
  36. package/doc/en/NATS.md +246 -0
  37. package/doc/en/NativeAddons.md +212 -0
  38. package/doc/en/Objects.md +143 -0
  39. package/doc/en/Overview.md +62 -85
  40. package/doc/en/Packaging.md +193 -0
  41. package/doc/en/ProjectStructure.md +124 -0
  42. package/doc/en/Queue.md +269 -0
  43. package/doc/en/Schedules.md +217 -0
  44. package/doc/en/Security.md +10 -9
  45. package/doc/en/ServiceCalls.md +504 -0
  46. package/doc/en/Time.md +19 -20
  47. package/doc/en/UpgradingFrom1.md +435 -0
  48. package/doc/en/UpgradingFrom2.md +315 -0
  49. package/doc/en/Workspaces.md +189 -0
  50. package/doc/en/examples/BackgroundJobs.md +677 -0
  51. package/doc/en/examples/CommandLineTool.md +664 -0
  52. package/doc/en/examples/DesktopApp.md +809 -0
  53. package/doc/en/examples/DeviceService.md +607 -0
  54. package/doc/en/examples/MCPServer.md +653 -0
  55. package/doc/en/examples/README.md +16 -0
  56. package/doc/en/examples/RestAPI.md +490 -0
  57. package/doc/en/examples/ServiceCalls.md +829 -0
  58. package/doc/en/examples/WebApp.md +568 -0
  59. package/doc/zh/Application.md +75 -0
  60. package/doc/zh/CLI.md +250 -0
  61. package/doc/zh/Cache.md +328 -0
  62. package/doc/zh/Command.md +78 -0
  63. package/doc/zh/ContainerdIntegration.md +9 -9
  64. package/doc/zh/Controllers.md +228 -0
  65. package/doc/zh/DBus.md +257 -0
  66. package/doc/zh/DTOValidation.md +49 -19
  67. package/doc/zh/Database.md +175 -0
  68. package/doc/zh/DatabaseDrivers.md +261 -0
  69. package/doc/zh/Decorator.md +25 -18
  70. package/doc/zh/DockerIntegration.md +6 -6
  71. package/doc/zh/Entities.md +224 -0
  72. package/doc/zh/Entrypoint.md +193 -0
  73. package/doc/zh/Exceptions.md +158 -0
  74. package/doc/zh/GRPC.md +226 -0
  75. package/doc/zh/GettingStarted.md +148 -0
  76. package/doc/zh/HTTP.md +445 -0
  77. package/doc/zh/HelperFunctions.md +2 -2
  78. package/doc/zh/I18n.md +26 -26
  79. package/doc/zh/Injection.md +129 -0
  80. package/doc/zh/Installers.md +232 -0
  81. package/doc/zh/Logger.md +188 -0
  82. package/doc/zh/MCP.md +265 -0
  83. package/doc/zh/Middlewares.md +198 -0
  84. package/doc/zh/Migrations.md +178 -0
  85. package/doc/zh/MongoEmbedded.md +118 -0
  86. package/doc/zh/Monitor.md +145 -0
  87. package/doc/zh/Mutex.md +10 -10
  88. package/doc/zh/NATS.md +207 -0
  89. package/doc/zh/NativeAddons.md +180 -0
  90. package/doc/zh/Objects.md +119 -0
  91. package/doc/zh/Overview.md +46 -71
  92. package/doc/zh/Packaging.md +154 -0
  93. package/doc/zh/ProjectStructure.md +102 -0
  94. package/doc/zh/Queue.md +214 -0
  95. package/doc/zh/Schedules.md +193 -0
  96. package/doc/zh/Security.md +6 -6
  97. package/doc/zh/ServiceCalls.md +459 -0
  98. package/doc/zh/Time.md +2 -2
  99. package/doc/zh/UpgradingFrom1.md +347 -0
  100. package/doc/zh/UpgradingFrom2.md +230 -0
  101. package/doc/zh/Workspaces.md +150 -0
  102. package/doc/zh/examples/BackgroundJobs.md +618 -0
  103. package/doc/zh/examples/CommandLineTool.md +618 -0
  104. package/doc/zh/examples/DesktopApp.md +748 -0
  105. package/doc/zh/examples/DeviceService.md +546 -0
  106. package/doc/zh/examples/MCPServer.md +604 -0
  107. package/doc/zh/examples/README.md +14 -0
  108. package/doc/zh/examples/RestAPI.md +458 -0
  109. package/doc/zh/examples/ServiceCalls.md +765 -0
  110. package/doc/zh/examples/WebApp.md +520 -0
  111. package/package.json +46 -17
  112. package/doc/en/ActionPattern.md +0 -408
  113. package/doc/en/Core.md +0 -312
  114. package/doc/en/ORMIntegration.md +0 -341
  115. package/doc/zh/ActionPattern.md +0 -334
  116. package/doc/zh/Core.md +0 -256
  117. package/doc/zh/ORMIntegration.md +0 -319
@@ -0,0 +1,520 @@
1
+ # 带界面的 Web 应用
2
+
3
+ 这个示例构建一个共享的购物清单:家里每个人都可以在手机或电脑上打开同一个网页,看到同一份清单,把缺的东西加进去,把买到的删掉。它从 `web-react` 模板开始,项目是一个包含两个包的工作区:应用在 `/api` 下提供 HTTP API,由 Vite 构建的 React 页面调用这个 API。示例展示页面如何访问 API:开发时经由 Vite 的开发服务器,构建之后则由应用自己提供页面;还展示单文件包如何把页面作为资源带上,以及如何测试 API。
4
+
5
+ | 方法 | 路由 | 作用 |
6
+ |---|---|---|
7
+ | `GET` | `/api/items` | 列出所有条目 |
8
+ | `POST` | `/api/items` | 添加一项:`{"name": "Milk"}`,返回整个清单 |
9
+ | `DELETE` | `/api/items/:id` | 删除一项,返回整个清单 |
10
+ | `GET` | `/` 以及其他页面 | 清单页面,即构建好的 `ui` 包 |
11
+
12
+ ## 1. 创建项目
13
+
14
+ ```bash
15
+ npx @lakutata/cli create --name shopping --template web-react --yes
16
+ cd shopping
17
+ rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
18
+ rm packages/app/src/middlewares/Authenticate.ts
19
+ ```
20
+
21
+ 项目是一个工作区(见 [ProjectStructure](../ProjectStructure.md)):`packages/app` 是应用,`packages/ui` 是 React 页面。这里删掉了模板中的示例控制器,也删掉了中间件 `Authenticate.ts`:它要求请求带上一个令牌,而浏览器中的页面无法保管这样的秘密。任何能访问到应用的人都能使用这份清单,在家庭网络中这没有问题;其他场合的做法见第 11 节。
22
+
23
+ ## 2. 清单
24
+
25
+ **`packages/app/src/components/ShoppingList.ts`**
26
+
27
+ ```typescript
28
+ import {Component} from 'lakutata'
29
+
30
+ /**
31
+ * 清单中的一项
32
+ */
33
+ export interface Item {
34
+ id: number
35
+ name: string
36
+ }
37
+
38
+ /**
39
+ * 购物清单,保存在内存中:只有一个实例,所有请求共用
40
+ */
41
+ export class ShoppingList extends Component {
42
+
43
+ protected readonly items: Map<number, Item> = new Map()
44
+
45
+ protected lastId: number = 0
46
+
47
+ /**
48
+ * 所有条目,按添加的先后排列
49
+ */
50
+ public all(): Item[] {
51
+ return [...this.items.values()]
52
+ }
53
+
54
+ /**
55
+ * 添加一项
56
+ * @param name
57
+ */
58
+ public add(name: string): void {
59
+ const item: Item = {id: ++this.lastId, name: name}
60
+ this.items.set(item.id, item)
61
+ }
62
+
63
+ /**
64
+ * 删除一项;这一项已经删除时什么也不做
65
+ * @param id
66
+ */
67
+ public remove(id: number): void {
68
+ this.items.delete(id)
69
+ }
70
+ }
71
+ ```
72
+
73
+ 控制器只在一个请求期间存在(见 [Controllers](../Controllers.md)),所以清单不能放在控制器里,而是放在组件中:组件只创建一次,由整个应用共用(见 [Objects](../Objects.md))。清单保存在内存中,应用停止后就会丢失(见第 11 节)。
74
+
75
+ ## 3. 输入
76
+
77
+ **`packages/app/src/dtos/ItemDTO.ts`**
78
+
79
+ ```typescript
80
+ import {DTO} from 'lakutata'
81
+ import {Expect} from 'lakutata/decorator/dto'
82
+
83
+ /**
84
+ * 新条目,名称去掉首尾的空格(有了 strict(false),trim() 才会去掉空格)
85
+ */
86
+ export class NewItemDTO extends DTO {
87
+ @Expect(DTO.String().trim().strict(false).min(1).max(100).required())
88
+ public name: string
89
+ }
90
+
91
+ /**
92
+ * 按 id 指定的条目(路由中的 :id 是文本,由 strict(false) 转换)
93
+ */
94
+ export class ItemIdDTO extends DTO {
95
+ @Expect(DTO.Number().integer().positive().strict(false).required())
96
+ public id: number
97
+ }
98
+ ```
99
+
100
+ 每个动作在运行前都会用 DTO 校验输入,拒绝不合法的输入(见 [DTOValidation](../DTOValidation.md)):
101
+
102
+ - 规则默认是严格的:只写 `.trim()` 时,`' Bread '` 会被拒绝;加上 `.strict(false)` 后,它会去掉两端的空格,只含空格的名称去掉空格后为空,会被拒绝;
103
+ - 路由中的 `:id` 是文本:`.strict(false)` 把 `'1'` 转换为数字 1。
104
+
105
+ ## 4. 控制器
106
+
107
+ **`packages/app/src/controllers/ItemController.ts`**
108
+
109
+ ```typescript
110
+ import {Controller} from 'lakutata/com/entrypoint'
111
+ import {DELETE, GET, POST, Prefix} from 'lakutata/decorator/ctrl'
112
+ import {Inject} from 'lakutata/decorator/di'
113
+ import {type Item, ShoppingList} from '../components/ShoppingList.js'
114
+ import {ItemIdDTO, NewItemDTO} from '../dtos/ItemDTO.js'
115
+
116
+ @Prefix({route: '/api/items'})
117
+ export class ItemController extends Controller {
118
+
119
+ /**
120
+ * 购物清单(选项中的 "list" 组件)
121
+ */
122
+ @Inject('list')
123
+ protected readonly list: ShoppingList
124
+
125
+ /**
126
+ * GET /api/items
127
+ */
128
+ @GET('/')
129
+ public async all(): Promise<Item[]> {
130
+ return this.list.all()
131
+ }
132
+
133
+ /**
134
+ * POST /api/items {"name": "Milk"}:返回加入新条目后的清单
135
+ * @param input
136
+ */
137
+ @POST('/', NewItemDTO)
138
+ public async add(input: NewItemDTO): Promise<Item[]> {
139
+ this.list.add(input.name)
140
+ return this.list.all()
141
+ }
142
+
143
+ /**
144
+ * DELETE /api/items/1:返回删除这一项后的清单
145
+ * @param input
146
+ */
147
+ @DELETE('/:id', ItemIdDTO)
148
+ public async remove(input: ItemIdDTO): Promise<Item[]> {
149
+ this.list.remove(input.id)
150
+ return this.list.all()
151
+ }
152
+ }
153
+ ```
154
+
155
+ - `@Prefix({route: '/api/items'})` 把这些动作放在 `/api/items` 之下(见 [HTTP](../HTTP.md))。API 都放在 `/api` 下,其他路径就可以留给应用的页面(见第 5 节)。
156
+ - `@Inject('list')` 把选项中的 `list` 组件交给控制器。
157
+ - `POST` 和 `DELETE` 返回整个清单,页面可以立即显示它,其中也包括别人做的修改。
158
+ - 删除已经删除的条目不算错误:两个人可能同时删掉同一项。
159
+
160
+ ## 5. 应用的选项
161
+
162
+ **`packages/app/src/config/Config.ts`**
163
+
164
+ ```typescript
165
+ import {resourcesPath, type ApplicationOptions} from 'lakutata'
166
+ import {buildEntrypoints} from 'lakutata/com/entrypoint'
167
+ import {expressHTTPEntrypoint, jsonEnvelope, staticUI} from 'lakutata/adapter/express'
168
+ import compression from 'compression'
169
+ import {existsSync} from 'node:fs'
170
+ import {createRequire} from 'node:module'
171
+ import path from 'node:path'
172
+ import {ShoppingList} from '../components/ShoppingList.js'
173
+ import {HealthController} from '../controllers/HealthController.js'
174
+ import {ItemController} from '../controllers/ItemController.js'
175
+
176
+ /**
177
+ * 构建好的 ui 包所在的目录,构建之前为 undefined(开发时由 Vite 提供页面):打包后的应用(lakutata package bundle 或
178
+ * lakutata package exe)在资源中找(来自 package.json 中的 "lakutata.resources"),否则在 ui 包中找
179
+ */
180
+ function uiDirectory(): string | undefined {
181
+ const resources: string | undefined = resourcesPath()
182
+ try {
183
+ const directory: string = resources ? path.join(resources, 'ui') : path.join(path.dirname(createRequire(import.meta.url).resolve('ui/package.json')), 'dist')
184
+ return existsSync(path.join(directory, 'index.html')) ? directory : undefined
185
+ } catch {
186
+ return undefined
187
+ }
188
+ }
189
+
190
+ /**
191
+ * 服务的选项
192
+ * @param port HTTP 服务器的端口,默认取环境变量 PORT,没有时为 8080
193
+ */
194
+ export function config(port: number = Number(process.env.PORT ?? 8080)): ApplicationOptions {
195
+ return {
196
+ id: 'shopping',
197
+ name: 'shopping',
198
+ mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
199
+ components: {
200
+ //购物清单,第一个用到它的请求到来时创建
201
+ list: {class: ShoppingList},
202
+ entrypoint: buildEntrypoints({
203
+ controllers: [HealthController, ItemController],
204
+ http: expressHTTPEntrypoint({
205
+ port: port,
206
+ //结果和错误都是 {errno, errMsg, data},状态码都是 200
207
+ respond: jsonEnvelope({errorStatus: 200}),
208
+ body: {limit: '100mb'},
209
+ server: {keepAliveTimeout: 60000, headersTimeout: 65000},
210
+ before: (app): void => void app.use(compression()),
211
+ //没有动作处理的路径都是应用的页面
212
+ fallback: ((directory: string | undefined) => directory ? staticUI(directory) : undefined)(uiDirectory())
213
+ })
214
+ })
215
+ },
216
+ bootstrap: ['entrypoint']
217
+ }
218
+ }
219
+ ```
220
+
221
+ 这是模板中的 `Config.ts`,加入了 `list` 组件,用 `ItemController` 替换了示例控制器,并去掉了 `authenticate` 中间件。提供页面的代码沿用模板:
222
+
223
+ - `fallback` 响应动作路由(`/api/…` 和 `/health`)之外的请求。`staticUI()` 发送构建好的页面中的文件;对于页面的路径,它返回页面的 `index.html`,由页面自己的路由处理(见 [HTTP](../HTTP.md))。
224
+ - `uiDirectory()` 查找构建好的页面:打包后的应用在资源中找(`resourcesPath()`,见第 11 节),否则在 `ui` 包的 `dist` 目录中找。页面构建之前没有 `fallback`:开发时由 Vite 提供页面。
225
+ - `jsonEnvelope({errorStatus: 200})` 让结果和错误都以 `{errno, errMsg, data}` 返回,状态码都是 200:页面根据 `errno` 区分两者。
226
+
227
+ ## 6. 调用 API
228
+
229
+ **`packages/ui/src/api.ts`**
230
+
231
+ ```typescript
232
+ /**
233
+ * 清单中的一项
234
+ */
235
+ export type Item = {
236
+ id: number
237
+ name: string
238
+ }
239
+
240
+ /**
241
+ * 向应用的 API 发一个请求(与页面同源):返回 {errno, errMsg, data} 中的 data,出错时抛出错误
242
+ * @param method
243
+ * @param route
244
+ * @param body
245
+ */
246
+ async function request<T>(method: string, route: string, body?: unknown): Promise<T> {
247
+ const response: Response = await fetch(`/api${route}`, {
248
+ method: method,
249
+ headers: {'Content-Type': 'application/json'},
250
+ body: body === undefined ? undefined : JSON.stringify(body)
251
+ })
252
+ const result = await response.json() as { errno: string | null, errMsg: string | null, data: T }
253
+ if (result.errno) throw new Error(result.errMsg ?? result.errno)
254
+ return result.data
255
+ }
256
+
257
+ export const listItems = (): Promise<Item[]> => request('GET', '/items')
258
+
259
+ export const addItem = (name: string): Promise<Item[]> => request('POST', '/items', {name: name})
260
+
261
+ export const removeItem = (id: number): Promise<Item[]> => request('DELETE', `/items/${id}`)
262
+ ```
263
+
264
+ 页面只用路径调用 API(`/api/items`),所以请求发往页面所在的源:开发时是 Vite 的开发服务器,构建之后是应用本身。不需要在任何地方配置 API 的地址,浏览器也不会发出需要 CORS 的跨域请求。`request()` 返回封套中的 `data`,出错时抛出错误,页面会显示错误消息,例如 `"name" is not allowed to be empty`。
265
+
266
+ ## 7. 页面
267
+
268
+ **`packages/ui/src/App.tsx`**
269
+
270
+ ```tsx
271
+ import {type FormEvent, useEffect, useState} from 'react'
272
+ import {addItem, type Item, listItems, removeItem} from './api'
273
+
274
+ export function App() {
275
+ const [items, setItems] = useState<Item[]>([])
276
+ const [name, setName] = useState<string>('')
277
+ const [error, setError] = useState<string>('')
278
+
279
+ /**
280
+ * 显示 API 返回的清单,出错时显示错误
281
+ * @param answer
282
+ */
283
+ async function show(answer: Promise<Item[]>): Promise<boolean> {
284
+ try {
285
+ setItems(await answer)
286
+ setError('')
287
+ return true
288
+ } catch (failure) {
289
+ setError((failure as Error).message)
290
+ return false
291
+ }
292
+ }
293
+
294
+ //先加载一次清单,之后每 5 秒加载一次,这样别人做的修改也会显示出来
295
+ useEffect((): (() => void) => {
296
+ void show(listItems())
297
+ const timer: number = window.setInterval((): void => void show(listItems()), 5000)
298
+ return (): void => window.clearInterval(timer)
299
+ }, [])
300
+
301
+ async function add(event: FormEvent<HTMLFormElement>): Promise<void> {
302
+ event.preventDefault()
303
+ if (await show(addItem(name))) setName('')
304
+ }
305
+
306
+ return (
307
+ <main>
308
+ <h1>Shopping list</h1>
309
+ <form onSubmit={add}>
310
+ <input value={name} onChange={(event): void => setName(event.target.value)} placeholder="Milk, bread…" aria-label="Item"/>
311
+ <button>Add</button>
312
+ </form>
313
+ {error && <p role="alert">{error}</p>}
314
+ {items.length === 0 && <p>The list is empty.</p>}
315
+ <ul>
316
+ {items.map((item: Item) => (
317
+ <li key={item.id}>
318
+ {item.name} <button onClick={(): void => void show(removeItem(item.id))}>Remove</button>
319
+ </li>
320
+ ))}
321
+ </ul>
322
+ </main>
323
+ )
324
+ }
325
+ ```
326
+
327
+ - `show()` 把 API 返回的清单显示在页面上,出错时显示错误。
328
+ - 页面打开时加载清单,之后每 5 秒重新加载一次:其他手机上添加或删除的条目也会显示出来。
329
+ - 模板中的 `main.tsx` 负责渲染 `App`,保持不变。
330
+
331
+ ## 8. 运行
332
+
333
+ ```bash
334
+ npm run dev
335
+ ```
336
+
337
+ 这条命令同时运行两个包的 `dev` 脚本(见 [Workspaces](../Workspaces.md)):应用先构建,再在 8080 端口监听;Vite 在 http://localhost:5173 上直接用源码提供页面,源码改动后浏览器中的页面会随之更新。打开 http://localhost:5173 即可。模板中的 `vite.config.ts` 把 `/api` 下的请求转发给应用:
338
+
339
+ ```typescript
340
+ server: {
341
+ proxy: {
342
+ '/api': `http://127.0.0.1:${process.env.PORT ?? 8080}`
343
+ }
344
+ }
345
+ ```
346
+
347
+ 所以经由 Vite 也能访问 API,和页面的调用方式相同:
348
+
349
+ ```bash
350
+ curl -X POST localhost:5173/api/items -H 'Content-Type: application/json' -d '{"name": "Milk"}'
351
+ # {"errno":null,"errMsg":null,"data":[{"id":1,"name":"Milk"}]}
352
+
353
+ curl -X POST localhost:5173/api/items -H 'Content-Type: application/json' -d '{"name": " Bread "}'
354
+ # {"errno":null,"errMsg":null,"data":[{"id":1,"name":"Milk"},{"id":2,"name":"Bread"}]}
355
+
356
+ curl -X DELETE localhost:5173/api/items/1
357
+ # {"errno":null,"errMsg":null,"data":[{"id":2,"name":"Bread"}]}
358
+
359
+ curl -i -X POST localhost:5173/api/items -H 'Content-Type: application/json' -d '{"name": ""}'
360
+ # HTTP/1.1 200 OK
361
+ # {"errno":"E_INVALID_VALUE","errMsg":"\"name\" is not allowed to be empty","data":null}
362
+ ```
363
+
364
+ 页面的改动会立即生效。应用的改动则要重新运行 `npm run dev`,因为应用的 `dev` 脚本是先构建再启动。如果之前运行过 `npm run build`,应用也会在 8080 端口提供那次构建出的页面:开发时请使用 Vite 提供的页面,它才是最新的。
365
+
366
+ ## 9. 测试
367
+
368
+ 模板中的测试换成了针对清单的测试。它在自己的端口上启动应用,调用 API,然后请求页面:
369
+
370
+ **`packages/app/src/tests/App.spec.ts`**
371
+
372
+ ```typescript
373
+ import {after, before, describe, it} from 'node:test'
374
+ import assert from 'node:assert'
375
+ import {Application} from 'lakutata'
376
+ import {config} from '../config/Config.js'
377
+
378
+ const PORT: number = Number(process.env.TEST_PORT ?? 18083)
379
+
380
+ /**
381
+ * 向 API 发一个请求,返回它的 {errno, errMsg, data}
382
+ * @param method
383
+ * @param route
384
+ * @param body
385
+ */
386
+ async function request(method: string, route: string, body?: unknown): Promise<{ errno: string | null, errMsg: string | null, data: any }> {
387
+ const response: Response = await fetch(`http://127.0.0.1:${PORT}${route}`, {
388
+ method: method,
389
+ headers: {'Content-Type': 'application/json'},
390
+ body: body === undefined ? undefined : JSON.stringify(body)
391
+ })
392
+ return await response.json() as { errno: string | null, errMsg: string | null, data: any }
393
+ }
394
+
395
+ describe('shopping', (): void => {
396
+
397
+ let app: Application
398
+
399
+ before(async (): Promise<void> => {
400
+ app = await new Promise<Application>((resolve: (app: Application) => void): void => {
401
+ Application.run(config(PORT)).onLaunched((launched: Application): void => resolve(launched))
402
+ })
403
+ })
404
+
405
+ after(async (): Promise<void> => {
406
+ await app?.dispose()
407
+ })
408
+
409
+ it('adds, lists and removes the items', async (): Promise<void> => {
410
+ assert.deepStrictEqual((await request('GET', '/api/items')).data, [])
411
+ await request('POST', '/api/items', {name: 'Milk'})
412
+ const added = await request('POST', '/api/items', {name: ' Bread '})
413
+ assert.deepStrictEqual(added.data, [{id: 1, name: 'Milk'}, {id: 2, name: 'Bread'}])
414
+ assert.deepStrictEqual((await request('DELETE', '/api/items/1')).data, [{id: 2, name: 'Bread'}])
415
+ //再删除一次不会有任何变化:别人可能已经先删掉了它
416
+ assert.deepStrictEqual((await request('DELETE', '/api/items/1')).data, [{id: 2, name: 'Bread'}])
417
+ assert.deepStrictEqual((await request('GET', '/api/items')).data, [{id: 2, name: 'Bread'}])
418
+ })
419
+
420
+ it('refuses an item without a name', async (): Promise<void> => {
421
+ assert.deepStrictEqual(await request('POST', '/api/items', {name: ' '}), {errno: 'E_INVALID_VALUE', errMsg: '"name" is not allowed to be empty', data: null})
422
+ })
423
+
424
+ it('serves the page of the list', async (): Promise<void> => {
425
+ const response: Response = await fetch(`http://127.0.0.1:${PORT}/`, {headers: {Accept: 'text/html'}})
426
+ assert.strictEqual(response.status, 200)
427
+ assert.match(await response.text(), /<div id="root"><\/div>/)
428
+ })
429
+ })
430
+ ```
431
+
432
+ ```bash
433
+ npm test
434
+ ```
435
+
436
+ ```
437
+ app │ ✔ adds, lists and removes the items
438
+ app │ ✔ refuses an item without a name
439
+ app │ ✔ serves the page of the list
440
+ app │ ℹ pass 3
441
+ ✔ test ran in app
442
+ ```
443
+
444
+ `npm test` 会先构建所有包,所以运行最后一个测试时,构建好的页面已经就绪。这些测试覆盖的是 API,而不是 React 代码;页面的类型由 `npm run typecheck -w ui` 检查,Vite 构建时并不检查类型。
445
+
446
+ ## 10. 构建
447
+
448
+ ```bash
449
+ npm run build
450
+ ```
451
+
452
+ ```
453
+ ui │ dist/index.html 0.31 kB │ gzip: 0.22 kB
454
+ ui │ dist/assets/index-D0SES_81.js 220.75 kB │ gzip: 69.11 kB
455
+ ui │ ✓ built in 49ms
456
+ app │ > tsc
457
+ ✔ rebuild ran in ui, app
458
+ ```
459
+
460
+ `ui` 包先构建,因为应用依赖它(应用的 `package.json` 中有 `"ui": "^0.0.1"`):Vite 把页面写到 `packages/ui/dist`,然后 `tsc` 把应用编译到 `packages/app/build`。
461
+
462
+ ```bash
463
+ npm start
464
+ ```
465
+
466
+ 现在应用在同一个进程、同一个端口上提供 API 和页面:<http://localhost:8080>。它在所有网络接口上监听,所以局域网中的手机用这台电脑的地址和 8080 端口就能打开页面。
467
+
468
+ ```bash
469
+ curl -i localhost:8080/ -H 'Accept: text/html'
470
+ # HTTP/1.1 200 OK
471
+ # Cache-Control: no-cache
472
+ # Content-Type: text/html; charset=utf-8
473
+ #
474
+ # <!doctype html>
475
+ # ...
476
+ # <script type="module" crossorigin src="/assets/index-D0SES_81.js"></script>
477
+ # ...
478
+
479
+ curl -I localhost:8080/assets/index-D0SES_81.js
480
+ # HTTP/1.1 200 OK
481
+ # Cache-Control: public, max-age=31536000, immutable
482
+ # Content-Type: text/javascript; charset=utf-8
483
+ ```
484
+
485
+ 浏览器每次请求都会重新验证 `index.html`;脚本的文件名中带有内容的哈希,浏览器会把它们缓存一年,因此新的构建会立即生效。
486
+
487
+ ## 11. 打包
488
+
489
+ 模板中应用的 `package.json` 把构建好的页面声明为资源:
490
+
491
+ ```json
492
+ "lakutata": {
493
+ "resources": [
494
+ {
495
+ "from": "../ui/dist",
496
+ "to": "ui"
497
+ }
498
+ ]
499
+ }
500
+ ```
501
+
502
+ 打包后的应用会带上它的资源,`resourcesPath()` 给出资源所在的目录,`uiDirectory()`(见第 5 节)就在那里找到页面(见 [Packaging](../Packaging.md))。以单文件包为例:
503
+
504
+ ```bash
505
+ npm install --save-dev @lakutata/builder
506
+ npm run build
507
+ cd packages/app
508
+ npx lakutata package bundle
509
+ ```
510
+
511
+ ```
512
+ ✔ dist/app.cjs (1.7 MB): node dist/app.cjs
513
+ ✔ dist/app.resources: the resources (ui), distributed with the bundle
514
+ ```
515
+
516
+ `dist/app.cjs` 和 `dist/app.resources` 就是整个应用:把它们一起复制到装有 Node.js 的机器上,运行 `node app.cjs` 就能提供 API 和页面,不需要 `node_modules`。
517
+
518
+ - `npm run docker:build` 用模板中的 `Dockerfile` 构建应用的镜像,页面也包含在内。
519
+ - 应用停止后清单就会丢失:可以像 [带数据库的 REST API](RestAPI.md) 那样把它保存到数据库中。
520
+ - 任何能访问到应用的人都能修改清单。在家庭网络之外,请把它放在要求登录的反向代理后面,或者在中间件中验证用户身份(见 [Middlewares](../Middlewares.md))。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lakutata",
3
- "version": "3.0.0",
4
- "description": "An IoC-based universal application framework.",
3
+ "version": "3.1.0",
4
+ "description": "An IoC application framework for Node.js and TypeScript: one programming model for web services, CLI tools, desktop (Electron) and embedded applications.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
@@ -94,6 +94,16 @@
94
94
  "default": "./dist/cjs/exports/MutexComponent.js"
95
95
  }
96
96
  },
97
+ "./com/queue": {
98
+ "import": {
99
+ "types": "./dist/types/exports/QueueComponent.d.ts",
100
+ "default": "./dist/esm/exports/QueueComponent.js"
101
+ },
102
+ "require": {
103
+ "types": "./dist/cjs/exports/QueueComponent.d.ts",
104
+ "default": "./dist/cjs/exports/QueueComponent.js"
105
+ }
106
+ },
97
107
  "./com/i18n": {
98
108
  "import": {
99
109
  "types": "./dist/types/exports/I18nComponent.d.ts",
@@ -304,7 +314,7 @@
304
314
  }
305
315
  },
306
316
  "engines": {
307
- "node": ">=22"
317
+ "node": "^22.13.0 || >=24.11.0"
308
318
  },
309
319
  "author": "alex.ma",
310
320
  "license": "MIT",
@@ -313,7 +323,8 @@
313
323
  },
314
324
  "repository": {
315
325
  "type": "git",
316
- "url": "https://github.com/lakutata/lakutata.git"
326
+ "url": "https://github.com/lakutata/lakutata.git",
327
+ "directory": "packages/lakutata"
317
328
  },
318
329
  "homepage": "https://lakutata.com",
319
330
  "bugs": {
@@ -323,21 +334,39 @@
323
334
  "keywords": [
324
335
  "lakutata",
325
336
  "framework",
326
- "typescript"
337
+ "ioc",
338
+ "dependency-injection",
339
+ "di",
340
+ "typescript",
341
+ "nodejs",
342
+ "decorators",
343
+ "dto",
344
+ "validation",
345
+ "express",
346
+ "fastify",
347
+ "cli",
348
+ "electron",
349
+ "microservices",
350
+ "rpc",
351
+ "grpc",
352
+ "nats",
353
+ "typeorm",
354
+ "mcp"
327
355
  ],
328
356
  "dependencies": {
329
- "@lakutata/cache": "3.0.0",
330
- "@lakutata/containerd": "3.0.0",
331
- "@lakutata/core": "3.0.0",
332
- "@lakutata/docker": "3.0.0",
333
- "@lakutata/entrypoint": "3.0.0",
334
- "@lakutata/i18n": "3.0.0",
335
- "@lakutata/logger": "3.0.0",
336
- "@lakutata/monitor": "3.0.0",
337
- "@lakutata/mutex": "3.0.0",
338
- "@lakutata/orm": "3.0.0",
339
- "@lakutata/security": "3.0.0",
340
- "@lakutata/utils": "3.0.0"
357
+ "@lakutata/cache": "3.1.0",
358
+ "@lakutata/containerd": "3.1.0",
359
+ "@lakutata/core": "3.1.0",
360
+ "@lakutata/docker": "3.1.0",
361
+ "@lakutata/entrypoint": "3.1.0",
362
+ "@lakutata/i18n": "3.1.0",
363
+ "@lakutata/logger": "3.1.0",
364
+ "@lakutata/monitor": "3.1.0",
365
+ "@lakutata/mutex": "3.1.0",
366
+ "@lakutata/orm": "3.1.0",
367
+ "@lakutata/queue": "3.1.0",
368
+ "@lakutata/security": "3.1.0",
369
+ "@lakutata/utils": "3.1.0"
341
370
  },
342
371
  "files": [
343
372
  "dist",