lakutata 3.0.0 → 3.1.1

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 (121) 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 +511 -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 +63 -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 +508 -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/WebRTC.md +1364 -0
  50. package/doc/en/Workspaces.md +189 -0
  51. package/doc/en/examples/BackgroundJobs.md +677 -0
  52. package/doc/en/examples/CommandLineTool.md +664 -0
  53. package/doc/en/examples/DesktopApp.md +809 -0
  54. package/doc/en/examples/DeviceService.md +607 -0
  55. package/doc/en/examples/MCPServer.md +653 -0
  56. package/doc/en/examples/Meeting.md +573 -0
  57. package/doc/en/examples/README.md +17 -0
  58. package/doc/en/examples/RestAPI.md +490 -0
  59. package/doc/en/examples/ServiceCalls.md +829 -0
  60. package/doc/en/examples/WebApp.md +568 -0
  61. package/doc/zh/Application.md +75 -0
  62. package/doc/zh/CLI.md +250 -0
  63. package/doc/zh/Cache.md +328 -0
  64. package/doc/zh/Command.md +78 -0
  65. package/doc/zh/ContainerdIntegration.md +9 -9
  66. package/doc/zh/Controllers.md +228 -0
  67. package/doc/zh/DBus.md +257 -0
  68. package/doc/zh/DTOValidation.md +49 -19
  69. package/doc/zh/Database.md +175 -0
  70. package/doc/zh/DatabaseDrivers.md +261 -0
  71. package/doc/zh/Decorator.md +25 -18
  72. package/doc/zh/DockerIntegration.md +6 -6
  73. package/doc/zh/Entities.md +224 -0
  74. package/doc/zh/Entrypoint.md +193 -0
  75. package/doc/zh/Exceptions.md +158 -0
  76. package/doc/zh/GRPC.md +226 -0
  77. package/doc/zh/GettingStarted.md +148 -0
  78. package/doc/zh/HTTP.md +446 -0
  79. package/doc/zh/HelperFunctions.md +2 -2
  80. package/doc/zh/I18n.md +26 -26
  81. package/doc/zh/Injection.md +129 -0
  82. package/doc/zh/Installers.md +232 -0
  83. package/doc/zh/Logger.md +188 -0
  84. package/doc/zh/MCP.md +265 -0
  85. package/doc/zh/Middlewares.md +198 -0
  86. package/doc/zh/Migrations.md +178 -0
  87. package/doc/zh/MongoEmbedded.md +118 -0
  88. package/doc/zh/Monitor.md +145 -0
  89. package/doc/zh/Mutex.md +10 -10
  90. package/doc/zh/NATS.md +207 -0
  91. package/doc/zh/NativeAddons.md +180 -0
  92. package/doc/zh/Objects.md +119 -0
  93. package/doc/zh/Overview.md +47 -71
  94. package/doc/zh/Packaging.md +154 -0
  95. package/doc/zh/ProjectStructure.md +102 -0
  96. package/doc/zh/Queue.md +214 -0
  97. package/doc/zh/Schedules.md +193 -0
  98. package/doc/zh/Security.md +6 -6
  99. package/doc/zh/ServiceCalls.md +462 -0
  100. package/doc/zh/Time.md +2 -2
  101. package/doc/zh/UpgradingFrom1.md +347 -0
  102. package/doc/zh/UpgradingFrom2.md +230 -0
  103. package/doc/zh/WebRTC.md +1049 -0
  104. package/doc/zh/Workspaces.md +150 -0
  105. package/doc/zh/examples/BackgroundJobs.md +618 -0
  106. package/doc/zh/examples/CommandLineTool.md +618 -0
  107. package/doc/zh/examples/DesktopApp.md +748 -0
  108. package/doc/zh/examples/DeviceService.md +546 -0
  109. package/doc/zh/examples/MCPServer.md +604 -0
  110. package/doc/zh/examples/Meeting.md +524 -0
  111. package/doc/zh/examples/README.md +15 -0
  112. package/doc/zh/examples/RestAPI.md +458 -0
  113. package/doc/zh/examples/ServiceCalls.md +765 -0
  114. package/doc/zh/examples/WebApp.md +520 -0
  115. package/package.json +46 -17
  116. package/doc/en/ActionPattern.md +0 -408
  117. package/doc/en/Core.md +0 -312
  118. package/doc/en/ORMIntegration.md +0 -341
  119. package/doc/zh/ActionPattern.md +0 -334
  120. package/doc/zh/Core.md +0 -256
  121. package/doc/zh/ORMIntegration.md +0 -319
@@ -0,0 +1,568 @@
1
+ # A web application with a user interface
2
+
3
+ This example builds a shared shopping list: a web page that everyone in a household opens on a phone or a computer,
4
+ where they all see the same list, add what is missing and remove what they have bought. It starts from the `web-react`
5
+ template, a workspace of two packages: the application, which serves an HTTP API under `/api`, and a React page, built
6
+ by Vite, which calls it. The example shows how the page reaches the API: through the Vite dev server during
7
+ development, and directly once built, when the application serves the page itself. It also shows how a bundle carries
8
+ the page among its resources, and how to test the API.
9
+
10
+ | Method | Route | What it does |
11
+ |---|---|---|
12
+ | `GET` | `/api/items` | Lists the items |
13
+ | `POST` | `/api/items` | Adds an item, `{"name": "Milk"}`, and answers with the list |
14
+ | `DELETE` | `/api/items/:id` | Removes an item, and answers with the list |
15
+ | `GET` | `/`, and any other page | The list page: the built `ui` package |
16
+
17
+ ## 1. Create the project
18
+
19
+ ```bash
20
+ npx @lakutata/cli create --name shopping --template web-react --yes
21
+ cd shopping
22
+ rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
23
+ rm packages/app/src/middlewares/Authenticate.ts
24
+ ```
25
+
26
+ The project is a workspace ([ProjectStructure](../ProjectStructure.md)): `packages/app` is the application, and
27
+ `packages/ui` the React page. The example controller of the template is removed, and so is its middleware
28
+ `Authenticate.ts`, which asks for a token that a page in a browser cannot keep secret. Anyone who can reach the
29
+ application can use the list, which is fine on a home network; section 11 says what to do elsewhere.
30
+
31
+ ## 2. The list
32
+
33
+ **`packages/app/src/components/ShoppingList.ts`**
34
+
35
+ ```typescript
36
+ import {Component} from 'lakutata'
37
+
38
+ /**
39
+ * An item of the list
40
+ */
41
+ export interface Item {
42
+ id: number
43
+ name: string
44
+ }
45
+
46
+ /**
47
+ * The shopping list, kept in memory: one instance, shared by all the requests
48
+ */
49
+ export class ShoppingList extends Component {
50
+
51
+ protected readonly items: Map<number, Item> = new Map()
52
+
53
+ protected lastId: number = 0
54
+
55
+ /**
56
+ * The items, in the order they were added
57
+ */
58
+ public all(): Item[] {
59
+ return [...this.items.values()]
60
+ }
61
+
62
+ /**
63
+ * Add an item
64
+ * @param name
65
+ */
66
+ public add(name: string): void {
67
+ const item: Item = {id: ++this.lastId, name: name}
68
+ this.items.set(item.id, item)
69
+ }
70
+
71
+ /**
72
+ * Remove an item; nothing happens when it was already removed
73
+ * @param id
74
+ */
75
+ public remove(id: number): void {
76
+ this.items.delete(id)
77
+ }
78
+ }
79
+ ```
80
+
81
+ A controller lives for one request only ([Controllers](../Controllers.md)), so it cannot keep the list. A component
82
+ keeps it instead: it is created once and shared by the whole application ([Objects](../Objects.md)). The list is held
83
+ in memory, so it is lost when the application stops (section 11).
84
+
85
+ ## 3. The input
86
+
87
+ **`packages/app/src/dtos/ItemDTO.ts`**
88
+
89
+ ```typescript
90
+ import {DTO} from 'lakutata'
91
+ import {Expect} from 'lakutata/decorator/dto'
92
+
93
+ /**
94
+ * A new item, its name trimmed (strict(false) lets trim() remove the spaces around it)
95
+ */
96
+ export class NewItemDTO extends DTO {
97
+ @Expect(DTO.String().trim().strict(false).min(1).max(100).required())
98
+ public name: string
99
+ }
100
+
101
+ /**
102
+ * An item by its id (the :id of the route: text, converted by strict(false))
103
+ */
104
+ export class ItemIdDTO extends DTO {
105
+ @Expect(DTO.Number().integer().positive().strict(false).required())
106
+ public id: number
107
+ }
108
+ ```
109
+
110
+ Each action validates its input with a DTO, and refuses invalid input ([DTOValidation](../DTOValidation.md)):
111
+
112
+ - the schemas are strict: `.trim()` alone would refuse `' Bread '`. With `.strict(false)`, it removes the spaces, and
113
+ a name made of spaces only is refused as empty;
114
+ - the `:id` of the route is text: `.strict(false)` converts `'1'` to the number 1.
115
+
116
+ ## 4. The controller
117
+
118
+ **`packages/app/src/controllers/ItemController.ts`**
119
+
120
+ ```typescript
121
+ import {Controller} from 'lakutata/com/entrypoint'
122
+ import {DELETE, GET, POST, Prefix} from 'lakutata/decorator/ctrl'
123
+ import {Inject} from 'lakutata/decorator/di'
124
+ import {type Item, ShoppingList} from '../components/ShoppingList.js'
125
+ import {ItemIdDTO, NewItemDTO} from '../dtos/ItemDTO.js'
126
+
127
+ @Prefix({route: '/api/items'})
128
+ export class ItemController extends Controller {
129
+
130
+ /**
131
+ * The list (the component "list" of the options)
132
+ */
133
+ @Inject('list')
134
+ protected readonly list: ShoppingList
135
+
136
+ /**
137
+ * GET /api/items
138
+ */
139
+ @GET('/')
140
+ public async all(): Promise<Item[]> {
141
+ return this.list.all()
142
+ }
143
+
144
+ /**
145
+ * POST /api/items {"name": "Milk"}: the list with the new item
146
+ * @param input
147
+ */
148
+ @POST('/', NewItemDTO)
149
+ public async add(input: NewItemDTO): Promise<Item[]> {
150
+ this.list.add(input.name)
151
+ return this.list.all()
152
+ }
153
+
154
+ /**
155
+ * DELETE /api/items/1: the list without the item
156
+ * @param input
157
+ */
158
+ @DELETE('/:id', ItemIdDTO)
159
+ public async remove(input: ItemIdDTO): Promise<Item[]> {
160
+ this.list.remove(input.id)
161
+ return this.list.all()
162
+ }
163
+ }
164
+ ```
165
+
166
+ - `@Prefix({route: '/api/items'})` puts the actions under `/api/items` ([HTTP](../HTTP.md)). Keeping the API under
167
+ `/api` leaves the other paths to the pages of the application (section 5).
168
+ - `@Inject('list')` gives the controller the `list` component of the options.
169
+ - `POST` and `DELETE` answer with the whole list, so the page can show it at once, including the changes made by
170
+ others.
171
+ - Removing an item that is already removed is not an error: two people may remove the same item at the same time.
172
+
173
+ ## 5. The options of the application
174
+
175
+ **`packages/app/src/config/Config.ts`**
176
+
177
+ ```typescript
178
+ import {resourcesPath, type ApplicationOptions} from 'lakutata'
179
+ import {buildEntrypoints} from 'lakutata/com/entrypoint'
180
+ import {expressHTTPEntrypoint, jsonEnvelope, staticUI} from 'lakutata/adapter/express'
181
+ import compression from 'compression'
182
+ import {existsSync} from 'node:fs'
183
+ import {createRequire} from 'node:module'
184
+ import path from 'node:path'
185
+ import {ShoppingList} from '../components/ShoppingList.js'
186
+ import {HealthController} from '../controllers/HealthController.js'
187
+ import {ItemController} from '../controllers/ItemController.js'
188
+
189
+ /**
190
+ * The directory of the built ui package, undefined before it is built (in development, Vite serves the page): among the
191
+ * resources of a packed application (lakutata package bundle or lakutata package exe, from "lakutata.resources" in its
192
+ * package.json), otherwise in the ui package
193
+ */
194
+ function uiDirectory(): string | undefined {
195
+ const resources: string | undefined = resourcesPath()
196
+ try {
197
+ const directory: string = resources ? path.join(resources, 'ui') : path.join(path.dirname(createRequire(import.meta.url).resolve('ui/package.json')), 'dist')
198
+ return existsSync(path.join(directory, 'index.html')) ? directory : undefined
199
+ } catch {
200
+ return undefined
201
+ }
202
+ }
203
+
204
+ /**
205
+ * The options of the service
206
+ * @param port the port of the HTTP server, the PORT environment variable or 8080 by default
207
+ */
208
+ export function config(port: number = Number(process.env.PORT ?? 8080)): ApplicationOptions {
209
+ return {
210
+ id: 'shopping',
211
+ name: 'shopping',
212
+ mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
213
+ components: {
214
+ //The shopping list, created when a request first needs it
215
+ list: {class: ShoppingList},
216
+ entrypoint: buildEntrypoints({
217
+ controllers: [HealthController, ItemController],
218
+ http: expressHTTPEntrypoint({
219
+ port: port,
220
+ //The results and the errors are {errno, errMsg, data}, with the status 200
221
+ respond: jsonEnvelope({errorStatus: 200}),
222
+ body: {limit: '100mb'},
223
+ server: {keepAliveTimeout: 60000, headersTimeout: 65000},
224
+ before: (app): void => void app.use(compression()),
225
+ //The paths that no action handles are the pages of the application
226
+ fallback: ((directory: string | undefined) => directory ? staticUI(directory) : undefined)(uiDirectory())
227
+ })
228
+ })
229
+ },
230
+ bootstrap: ['entrypoint']
231
+ }
232
+ }
233
+ ```
234
+
235
+ This is the `Config.ts` of the template, with the `list` component, the `ItemController` instead of the example
236
+ controller, and without the `authenticate` middleware. The code that serves the page comes from the template:
237
+
238
+ - `fallback` answers the requests that match no action route (`/api/…` and `/health`). `staticUI()` sends the files of
239
+ the built page, and its `index.html` for any page path, which the page's own router then handles
240
+ ([HTTP](../HTTP.md)).
241
+ - `uiDirectory()` finds the built page: among the resources of a packed application (`resourcesPath()`, section 11),
242
+ otherwise in the `dist` directory of the `ui` package. Before the page is built, there is no fallback: in
243
+ development, Vite serves the page.
244
+ - `jsonEnvelope({errorStatus: 200})` sends both the results and the errors as `{errno, errMsg, data}` with the status
245
+ 200: the page tells them apart by their `errno`.
246
+
247
+ ## 6. The calls to the API
248
+
249
+ **`packages/ui/src/api.ts`**
250
+
251
+ ```typescript
252
+ /**
253
+ * An item of the list
254
+ */
255
+ export type Item = {
256
+ id: number
257
+ name: string
258
+ }
259
+
260
+ /**
261
+ * A request to the API of the application, on the page's own origin: returns the data of its {errno, errMsg, data}, or
262
+ * throws its error
263
+ * @param method
264
+ * @param route
265
+ * @param body
266
+ */
267
+ async function request<T>(method: string, route: string, body?: unknown): Promise<T> {
268
+ const response: Response = await fetch(`/api${route}`, {
269
+ method: method,
270
+ headers: {'Content-Type': 'application/json'},
271
+ body: body === undefined ? undefined : JSON.stringify(body)
272
+ })
273
+ const result = await response.json() as { errno: string | null, errMsg: string | null, data: T }
274
+ if (result.errno) throw new Error(result.errMsg ?? result.errno)
275
+ return result.data
276
+ }
277
+
278
+ export const listItems = (): Promise<Item[]> => request('GET', '/items')
279
+
280
+ export const addItem = (name: string): Promise<Item[]> => request('POST', '/items', {name: name})
281
+
282
+ export const removeItem = (id: number): Promise<Item[]> => request('DELETE', `/items/${id}`)
283
+ ```
284
+
285
+ The page calls the API by its path only (`/api/items`), so the requests go to the origin the page came from: the Vite
286
+ dev server during development, the application once built. The address of the API is configured nowhere, and the
287
+ browser makes no cross-origin request, which would need CORS. `request()` returns the `data` of the envelope, or throws
288
+ its error, whose message the page shows, such as `"name" is not allowed to be empty`.
289
+
290
+ ## 7. The page
291
+
292
+ **`packages/ui/src/App.tsx`**
293
+
294
+ ```tsx
295
+ import {type FormEvent, useEffect, useState} from 'react'
296
+ import {addItem, type Item, listItems, removeItem} from './api'
297
+
298
+ export function App() {
299
+ const [items, setItems] = useState<Item[]>([])
300
+ const [name, setName] = useState<string>('')
301
+ const [error, setError] = useState<string>('')
302
+
303
+ /**
304
+ * Show the list that the API returns, or its error
305
+ * @param answer
306
+ */
307
+ async function show(answer: Promise<Item[]>): Promise<boolean> {
308
+ try {
309
+ setItems(await answer)
310
+ setError('')
311
+ return true
312
+ } catch (failure) {
313
+ setError((failure as Error).message)
314
+ return false
315
+ }
316
+ }
317
+
318
+ //Load the list now, then every 5 seconds, so that the changes made by others appear
319
+ useEffect((): (() => void) => {
320
+ void show(listItems())
321
+ const timer: number = window.setInterval((): void => void show(listItems()), 5000)
322
+ return (): void => window.clearInterval(timer)
323
+ }, [])
324
+
325
+ async function add(event: FormEvent<HTMLFormElement>): Promise<void> {
326
+ event.preventDefault()
327
+ if (await show(addItem(name))) setName('')
328
+ }
329
+
330
+ return (
331
+ <main>
332
+ <h1>Shopping list</h1>
333
+ <form onSubmit={add}>
334
+ <input value={name} onChange={(event): void => setName(event.target.value)} placeholder="Milk, bread…" aria-label="Item"/>
335
+ <button>Add</button>
336
+ </form>
337
+ {error && <p role="alert">{error}</p>}
338
+ {items.length === 0 && <p>The list is empty.</p>}
339
+ <ul>
340
+ {items.map((item: Item) => (
341
+ <li key={item.id}>
342
+ {item.name} <button onClick={(): void => void show(removeItem(item.id))}>Remove</button>
343
+ </li>
344
+ ))}
345
+ </ul>
346
+ </main>
347
+ )
348
+ }
349
+ ```
350
+
351
+ - `show()` shows the list that the API returns, or its error.
352
+ - The list is loaded when the page opens, then every 5 seconds, so the items added or removed on other phones appear.
353
+ - The `main.tsx` of the template, which renders `App`, does not change.
354
+
355
+ ## 8. Run it
356
+
357
+ ```bash
358
+ npm run dev
359
+ ```
360
+
361
+ This runs the `dev` scripts of both packages at once ([Workspaces](../Workspaces.md)): the application is built and
362
+ listens on port 8080, and Vite serves the page from its sources on http://localhost:5173, reloading it in the browser
363
+ when they change. Open http://localhost:5173. The `vite.config.ts` of the template forwards the requests under `/api`
364
+ to the application:
365
+
366
+ ```typescript
367
+ server: {
368
+ proxy: {
369
+ '/api': `http://127.0.0.1:${process.env.PORT ?? 8080}`
370
+ }
371
+ }
372
+ ```
373
+
374
+ So the API can also be called through Vite, the way the page calls it:
375
+
376
+ ```bash
377
+ curl -X POST localhost:5173/api/items -H 'Content-Type: application/json' -d '{"name": "Milk"}'
378
+ # {"errno":null,"errMsg":null,"data":[{"id":1,"name":"Milk"}]}
379
+
380
+ curl -X POST localhost:5173/api/items -H 'Content-Type: application/json' -d '{"name": " Bread "}'
381
+ # {"errno":null,"errMsg":null,"data":[{"id":1,"name":"Milk"},{"id":2,"name":"Bread"}]}
382
+
383
+ curl -X DELETE localhost:5173/api/items/1
384
+ # {"errno":null,"errMsg":null,"data":[{"id":2,"name":"Bread"}]}
385
+
386
+ curl -i -X POST localhost:5173/api/items -H 'Content-Type: application/json' -d '{"name": ""}'
387
+ # HTTP/1.1 200 OK
388
+ # {"errno":"E_INVALID_VALUE","errMsg":"\"name\" is not allowed to be empty","data":null}
389
+ ```
390
+
391
+ Changes to the page show at once. Changes to the application need `npm run dev` again, as its `dev` script builds it,
392
+ then starts it. If `npm run build` has run before, the application also serves the page of that build on port 8080;
393
+ during development, use the page from Vite, which is up to date.
394
+
395
+ ## 9. Test it
396
+
397
+ The test of the template is replaced by a test of the list. It starts the application on its own port, calls the API,
398
+ then requests the page:
399
+
400
+ **`packages/app/src/tests/App.spec.ts`**
401
+
402
+ ```typescript
403
+ import {after, before, describe, it} from 'node:test'
404
+ import assert from 'node:assert'
405
+ import {Application} from 'lakutata'
406
+ import {config} from '../config/Config.js'
407
+
408
+ const PORT: number = Number(process.env.TEST_PORT ?? 18083)
409
+
410
+ /**
411
+ * A request to the API: its {errno, errMsg, data}
412
+ * @param method
413
+ * @param route
414
+ * @param body
415
+ */
416
+ async function request(method: string, route: string, body?: unknown): Promise<{ errno: string | null, errMsg: string | null, data: any }> {
417
+ const response: Response = await fetch(`http://127.0.0.1:${PORT}${route}`, {
418
+ method: method,
419
+ headers: {'Content-Type': 'application/json'},
420
+ body: body === undefined ? undefined : JSON.stringify(body)
421
+ })
422
+ return await response.json() as { errno: string | null, errMsg: string | null, data: any }
423
+ }
424
+
425
+ describe('shopping', (): void => {
426
+
427
+ let app: Application
428
+
429
+ before(async (): Promise<void> => {
430
+ app = await new Promise<Application>((resolve: (app: Application) => void): void => {
431
+ Application.run(config(PORT)).onLaunched((launched: Application): void => resolve(launched))
432
+ })
433
+ })
434
+
435
+ after(async (): Promise<void> => {
436
+ await app?.dispose()
437
+ })
438
+
439
+ it('adds, lists and removes the items', async (): Promise<void> => {
440
+ assert.deepStrictEqual((await request('GET', '/api/items')).data, [])
441
+ await request('POST', '/api/items', {name: 'Milk'})
442
+ const added = await request('POST', '/api/items', {name: ' Bread '})
443
+ assert.deepStrictEqual(added.data, [{id: 1, name: 'Milk'}, {id: 2, name: 'Bread'}])
444
+ assert.deepStrictEqual((await request('DELETE', '/api/items/1')).data, [{id: 2, name: 'Bread'}])
445
+ //Removing it again changes nothing: someone else may have removed it first
446
+ assert.deepStrictEqual((await request('DELETE', '/api/items/1')).data, [{id: 2, name: 'Bread'}])
447
+ assert.deepStrictEqual((await request('GET', '/api/items')).data, [{id: 2, name: 'Bread'}])
448
+ })
449
+
450
+ it('refuses an item without a name', async (): Promise<void> => {
451
+ assert.deepStrictEqual(await request('POST', '/api/items', {name: ' '}), {errno: 'E_INVALID_VALUE', errMsg: '"name" is not allowed to be empty', data: null})
452
+ })
453
+
454
+ it('serves the page of the list', async (): Promise<void> => {
455
+ const response: Response = await fetch(`http://127.0.0.1:${PORT}/`, {headers: {Accept: 'text/html'}})
456
+ assert.strictEqual(response.status, 200)
457
+ assert.match(await response.text(), /<div id="root"><\/div>/)
458
+ })
459
+ })
460
+ ```
461
+
462
+ ```bash
463
+ npm test
464
+ ```
465
+
466
+ ```
467
+ app │ ✔ adds, lists and removes the items
468
+ app │ ✔ refuses an item without a name
469
+ app │ ✔ serves the page of the list
470
+ app │ ℹ pass 3
471
+ ✔ test ran in app
472
+ ```
473
+
474
+ `npm test` builds the packages first, so the built page is there for the last test. The tests cover the API, not the
475
+ React code; `npm run typecheck -w ui` checks the types of the page, which the Vite build does not.
476
+
477
+ ## 10. Build it
478
+
479
+ ```bash
480
+ npm run build
481
+ ```
482
+
483
+ ```
484
+ ui │ dist/index.html 0.31 kB │ gzip: 0.22 kB
485
+ ui │ dist/assets/index-D0SES_81.js 220.75 kB │ gzip: 69.11 kB
486
+ ui │ ✓ built in 49ms
487
+ app │ > tsc
488
+ ✔ rebuild ran in ui, app
489
+ ```
490
+
491
+ The `ui` package is built first, as the application depends on it (`"ui": "^0.0.1"` in its `package.json`): Vite
492
+ writes the page into `packages/ui/dist`, then `tsc` compiles the application into `packages/app/build`.
493
+
494
+ ```bash
495
+ npm start
496
+ ```
497
+
498
+ The application now serves both the API and the page, from one process and one port: http://localhost:8080. It
499
+ listens on all interfaces, so the phones on the network open the page at the computer's address, on port 8080.
500
+
501
+ ```bash
502
+ curl -i localhost:8080/ -H 'Accept: text/html'
503
+ # HTTP/1.1 200 OK
504
+ # Cache-Control: no-cache
505
+ # Content-Type: text/html; charset=utf-8
506
+ #
507
+ # <!doctype html>
508
+ # ...
509
+ # <script type="module" crossorigin src="/assets/index-D0SES_81.js"></script>
510
+ # ...
511
+
512
+ curl -I localhost:8080/assets/index-D0SES_81.js
513
+ # HTTP/1.1 200 OK
514
+ # Cache-Control: public, max-age=31536000, immutable
515
+ # Content-Type: text/javascript; charset=utf-8
516
+ ```
517
+
518
+ Browsers check `index.html` again on each request, and keep the scripts for a year, as their names hold a hash of
519
+ their content: a new build shows up at once.
520
+
521
+ ## 11. Package it
522
+
523
+ The `package.json` of the application, from the template, declares the built page as a resource:
524
+
525
+ ```json
526
+ "lakutata": {
527
+ "resources": [
528
+ {
529
+ "from": "../ui/dist",
530
+ "to": "ui"
531
+ }
532
+ ]
533
+ }
534
+ ```
535
+
536
+ A packed application carries its resources, and `resourcesPath()` gives their directory, where `uiDirectory()`
537
+ (section 5) finds the page ([Packaging](../Packaging.md)). A bundle, for example:
538
+
539
+ ```bash
540
+ npm install --save-dev @lakutata/builder
541
+ npm run build
542
+ cd packages/app
543
+ npx lakutata package bundle
544
+ ```
545
+
546
+ ```
547
+ ✔ dist/app.cjs (1.7 MB): node dist/app.cjs
548
+ ✔ dist/app.resources: the resources (ui), distributed with the bundle
549
+ ```
550
+
551
+ `dist/app.cjs` and `dist/app.resources` are the whole application: copy them together to a machine with Node.js, and
552
+ `node app.cjs` serves the API and the page, without `node_modules`.
553
+
554
+ - `npm run docker:build` builds the image of the application with the `Dockerfile` of the template, page included.
555
+ - The list is lost when the application stops: keep it in a database, as in
556
+ [A REST API with a database](RestAPI.md).
557
+ - Anyone who can reach the application can change the list. Outside a home network, put it behind a reverse proxy
558
+ that asks for a login, or authenticate the users in a middleware ([Middlewares](../Middlewares.md)).
559
+
560
+ <!--
561
+ check:
562
+ create: shopping web-react
563
+ remove: packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
564
+ remove: packages/app/src/middlewares/Authenticate.ts
565
+ run: npm test
566
+ run: npm run typecheck -w ui
567
+ run: cd packages/app && npx lakutata package bundle && test -f dist/app.resources/ui/index.html
568
+ -->
@@ -0,0 +1,75 @@
1
+ # Lakutata 应用
2
+
3
+ **应用**是 Lakutata 程序的根对象。`Application.run()` 在一个新的根容器中创建它,注册它声明的对象,依次执行 bootstrap 步骤,并在收到退出信号时优雅地结束。本页介绍应用的选项、链式 API、生命周期、运行模式和打包后的应用。应用创建的对象见 [Objects](Objects.md),对象之间如何获取彼此见 [Injection](Injection.md)。
4
+
5
+ ## 1. 选项
6
+
7
+ `Application.run(options)`(或 `run(() => options)`,在启动时调用该函数,可以是异步的)接受以下选项:
8
+
9
+ | 选项 | 含义 |
10
+ |---|---|
11
+ | `id`、`name` | 必填。应用的 ID(同时作为 `process.title`)和名称(`app.appId`、`app.appName`) |
12
+ | `timezone` | IANA 时区名,或 `'auto'`(默认)表示使用系统时区;写入 `TZ` |
13
+ | `mode` | `'development'` 或 `'production'`,在环境变量未指定时生效(见第 4 节) |
14
+ | `shutdownSignals` | 使应用优雅退出的信号(默认 `['SIGTERM', 'SIGINT']`),或 `false` |
15
+ | `components`、`providers` | 按名称声明的组件和提供者:`{class, ...options}` |
16
+ | `objects` | 其他对象:`{named: {...}, anonymous: [...]}` |
17
+ | `bootstrap` | 启动时依次执行的步骤:名称、symbol 或类(创建该对象),或以应用为参数调用的异步函数 |
18
+ | `checkOptions` | 启动时检查所声明对象的选项(默认 `true`) |
19
+
20
+ 不带 `class` 的声明会补充框架预置的同名对象的选项:`lakutata` 包预置了 `log`(`Logger`)和 `entrypoint`(`Entrypoint`),因此 `log: {level: 'debug'}` 会设置预置日志组件的级别。`Application.preset(options)` 为进程中的所有应用预置对象。
21
+
22
+ ## 2. 链式 API
23
+
24
+ ```typescript
25
+ import {Application, type ApplicationExitReason, type ILogger} from 'lakutata'
26
+
27
+ Application
28
+ //环境变量,在启动时、调用选项函数之前设置
29
+ .env({TZ_NAME: 'Asia/Shanghai'})
30
+ //路径别名:传给 path.resolve() 或 app.alias.resolve() 的路径中的 '@data';目录不存在时创建
31
+ .alias({'@data': './data'}, true)
32
+ .run(() => ({
33
+ id: 'com.example.app',
34
+ name: 'Example',
35
+ components: {}
36
+ }))
37
+ //bootstrap 完成后调用(不会被等待)
38
+ .onLaunched((app: Application, log: ILogger): void => log.info('%s launched in %s mode', app.appName, app.mode()))
39
+ //应用退出时、对象销毁之前调用,会被等待
40
+ .onDone(async (app: Application, log: ILogger, reason: ApplicationExitReason): Promise<void> => {
41
+ log.info('%s done (exit code %d%s)', app.appName, reason.code, reason.signal ? `, ${reason.signal}` : '')
42
+ })
43
+ //进程中未捕获的异常,应用继续运行
44
+ .onUncaughtException((error: Error, log: ILogger): void => log.warn(error.message))
45
+ //启动失败:返回的数字作为退出码(未返回时为 1)
46
+ .onFatalException((error: Error, log: ILogger): number => {
47
+ log.error('Launch failed: %s', error.message)
48
+ return 100
49
+ })
50
+ ```
51
+
52
+ `app.exit(code)` 以指定退出码优雅地结束应用;`app.exit(true)` 立即以退出码 2 结束进程,不执行 `onDone` 处理函数,也不销毁对象。
53
+
54
+ ## 3. 生命周期
55
+
56
+ 1. 设置环境变量,注册路径别名(`@runtime` 为当前工作目录)。
57
+ 2. 读取并校验选项;应用 ID、时区和运行模式。
58
+ 3. 在新的根容器中创建应用:注册它的对象,并依次执行 `bootstrap`。
59
+ 4. 执行各对象的 `launched()` 钩子和 `onLaunched` 处理函数。
60
+
61
+ - **启动失败**:应用无法启动时(选项不合法、对象初始化失败、bootstrap 步骤失败),由 `onFatalException` 的处理函数决定退出码(没有返回时为 1)。没有注册处理函数时,框架记录错误后以退出码 1 退出,和 Node.js 处理未捕获异常的方式一致,这样运行它的工具(Docker 的重启策略、Kubernetes、systemd、CI)都能知道启动失败了;已经创建的对象(正在监听的服务、数据库连接等)也不会让进程继续挂着。
62
+ - **退出信号**:收到 `SIGTERM`(`docker stop`、Kubernetes 滚动更新、systemd 停止服务)或 `SIGINT`(Ctrl+C)时,应用会优雅退出:先执行 `onDone` 处理函数,此时应用仍然完全可用(第三个参数说明退出原因:收到信号时是 `{code: 0, signal: 'SIGTERM'}`,调用 `app.exit(code)` 时是 `{code}`);然后各入口停止接收新请求并等待正在处理的请求完成,各对象被销毁,最后以退出码 0 退出。如果再次收到信号(比如退出过程卡住了),或者信号是在应用启动期间收到的,进程会立即以 128 + 信号编号退出(`SIGTERM` 为 143,`SIGINT` 为 130)。选项 `shutdownSignals` 可以指定其他信号(如 `['SIGTERM', 'SIGINT', 'SIGHUP']`),设为 `false` 则交由 Node.js 处理。
63
+ - **没有事情可做时**:当没有任何东西让进程继续运行时(没有服务、连接或定时器),应用会像调用 `app.exit(0)` 一样优雅退出:执行 `onDone` 处理函数,销毁对象,然后以进程当前的退出码(`process.exitCode`,默认 0)退出。所以脚本做完工作直接结束即可。已经被销毁的应用(例如测试中调用了 `app.dispose()`)则把进程何时结束交给运行它的程序。
64
+
65
+ ## 4. 运行模式
66
+
67
+ `app.mode()` 返回 `'development'` 或 `'production'`。环境变量会覆盖代码里的配置,同一份构建产物可以在不同模式下运行:依次看环境变量 `MODE`、`NODE_ENV`,然后是选项 `mode`,都没有时为 `'development'`。不是这两个值的 `MODE` 会发出警告并被忽略,不是这两个值的 `NODE_ENV`(如 `test`)会被跳过。确定的模式会写回 `MODE` 和 `NODE_ENV`,第三方库读到的也是同一个模式。环境变量可以来自系统、`Application.env({MODE: 'production'})`,或者 Bun 读取的 `.env` 文件(指 `bun App.js` 这种运行方式;Bun 构建的可执行文件不会读取它)。
68
+
69
+ ## 5. 打包后的应用
70
+
71
+ `lakutata package bundle`、`package exe` 和 `package installer` 把编译好的应用打包成单文件包、可执行文件或安装包。以打包形式运行时,`isPackaged()`(来自 `lakutata`)返回 `true`,`resourcesPath()` 返回资源所在的目录,资源就是 `package.json` 的 `lakutata.resources` 中声明的文件;未打包时,它返回 `undefined`,应用从这些文件的构建位置读取它们。详见 [Packaging](Packaging.md)。
72
+
73
+ ## 6. 入口
74
+
75
+ `entrypoint` 组件(`lakutata/com/entrypoint`)通过 HTTP、CLI、服务传输、定时任务和消息队列对外提供控制器。用 `buildEntrypoints({controllers, http, cli, service, schedule, queue})` 配置它,并把它列入 `bootstrap`;详见 [Entrypoint](Entrypoint.md)。