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.
- package/README.md +50 -30
- package/README.zh-CN.md +130 -0
- package/dist/cjs/exports/QueueComponent.d.ts +1 -0
- package/dist/cjs/exports/QueueComponent.js +20 -0
- package/dist/esm/exports/QueueComponent.js +1 -0
- package/dist/types/exports/QueueComponent.d.ts +1 -0
- package/doc/en/Application.md +107 -0
- package/doc/en/CLI.md +274 -0
- package/doc/en/Cache.md +379 -0
- package/doc/en/Command.md +110 -0
- package/doc/en/ContainerdIntegration.md +17 -16
- package/doc/en/Controllers.md +256 -0
- package/doc/en/DBus.md +291 -0
- package/doc/en/DTOValidation.md +72 -43
- package/doc/en/Database.md +188 -0
- package/doc/en/DatabaseDrivers.md +287 -0
- package/doc/en/Decorator.md +52 -40
- package/doc/en/DockerIntegration.md +7 -6
- package/doc/en/Entities.md +240 -0
- package/doc/en/Entrypoint.md +216 -0
- package/doc/en/Exceptions.md +174 -0
- package/doc/en/GRPC.md +251 -0
- package/doc/en/GettingStarted.md +165 -0
- package/doc/en/HTTP.md +511 -0
- package/doc/en/HelperFunctions.md +11 -11
- package/doc/en/I18n.md +119 -115
- package/doc/en/Injection.md +144 -0
- package/doc/en/Installers.md +280 -0
- package/doc/en/Logger.md +206 -0
- package/doc/en/MCP.md +293 -0
- package/doc/en/Middlewares.md +213 -0
- package/doc/en/Migrations.md +202 -0
- package/doc/en/MongoEmbedded.md +149 -0
- package/doc/en/Monitor.md +164 -0
- package/doc/en/Mutex.md +27 -25
- package/doc/en/NATS.md +246 -0
- package/doc/en/NativeAddons.md +212 -0
- package/doc/en/Objects.md +143 -0
- package/doc/en/Overview.md +63 -85
- package/doc/en/Packaging.md +193 -0
- package/doc/en/ProjectStructure.md +124 -0
- package/doc/en/Queue.md +269 -0
- package/doc/en/Schedules.md +217 -0
- package/doc/en/Security.md +10 -9
- package/doc/en/ServiceCalls.md +508 -0
- package/doc/en/Time.md +19 -20
- package/doc/en/UpgradingFrom1.md +435 -0
- package/doc/en/UpgradingFrom2.md +315 -0
- package/doc/en/WebRTC.md +1364 -0
- package/doc/en/Workspaces.md +189 -0
- package/doc/en/examples/BackgroundJobs.md +677 -0
- package/doc/en/examples/CommandLineTool.md +664 -0
- package/doc/en/examples/DesktopApp.md +809 -0
- package/doc/en/examples/DeviceService.md +607 -0
- package/doc/en/examples/MCPServer.md +653 -0
- package/doc/en/examples/Meeting.md +573 -0
- package/doc/en/examples/README.md +17 -0
- package/doc/en/examples/RestAPI.md +490 -0
- package/doc/en/examples/ServiceCalls.md +829 -0
- package/doc/en/examples/WebApp.md +568 -0
- package/doc/zh/Application.md +75 -0
- package/doc/zh/CLI.md +250 -0
- package/doc/zh/Cache.md +328 -0
- package/doc/zh/Command.md +78 -0
- package/doc/zh/ContainerdIntegration.md +9 -9
- package/doc/zh/Controllers.md +228 -0
- package/doc/zh/DBus.md +257 -0
- package/doc/zh/DTOValidation.md +49 -19
- package/doc/zh/Database.md +175 -0
- package/doc/zh/DatabaseDrivers.md +261 -0
- package/doc/zh/Decorator.md +25 -18
- package/doc/zh/DockerIntegration.md +6 -6
- package/doc/zh/Entities.md +224 -0
- package/doc/zh/Entrypoint.md +193 -0
- package/doc/zh/Exceptions.md +158 -0
- package/doc/zh/GRPC.md +226 -0
- package/doc/zh/GettingStarted.md +148 -0
- package/doc/zh/HTTP.md +446 -0
- package/doc/zh/HelperFunctions.md +2 -2
- package/doc/zh/I18n.md +26 -26
- package/doc/zh/Injection.md +129 -0
- package/doc/zh/Installers.md +232 -0
- package/doc/zh/Logger.md +188 -0
- package/doc/zh/MCP.md +265 -0
- package/doc/zh/Middlewares.md +198 -0
- package/doc/zh/Migrations.md +178 -0
- package/doc/zh/MongoEmbedded.md +118 -0
- package/doc/zh/Monitor.md +145 -0
- package/doc/zh/Mutex.md +10 -10
- package/doc/zh/NATS.md +207 -0
- package/doc/zh/NativeAddons.md +180 -0
- package/doc/zh/Objects.md +119 -0
- package/doc/zh/Overview.md +47 -71
- package/doc/zh/Packaging.md +154 -0
- package/doc/zh/ProjectStructure.md +102 -0
- package/doc/zh/Queue.md +214 -0
- package/doc/zh/Schedules.md +193 -0
- package/doc/zh/Security.md +6 -6
- package/doc/zh/ServiceCalls.md +462 -0
- package/doc/zh/Time.md +2 -2
- package/doc/zh/UpgradingFrom1.md +347 -0
- package/doc/zh/UpgradingFrom2.md +230 -0
- package/doc/zh/WebRTC.md +1049 -0
- package/doc/zh/Workspaces.md +150 -0
- package/doc/zh/examples/BackgroundJobs.md +618 -0
- package/doc/zh/examples/CommandLineTool.md +618 -0
- package/doc/zh/examples/DesktopApp.md +748 -0
- package/doc/zh/examples/DeviceService.md +546 -0
- package/doc/zh/examples/MCPServer.md +604 -0
- package/doc/zh/examples/Meeting.md +524 -0
- package/doc/zh/examples/README.md +15 -0
- package/doc/zh/examples/RestAPI.md +458 -0
- package/doc/zh/examples/ServiceCalls.md +765 -0
- package/doc/zh/examples/WebApp.md +520 -0
- package/package.json +46 -17
- package/doc/en/ActionPattern.md +0 -408
- package/doc/en/Core.md +0 -312
- package/doc/en/ORMIntegration.md +0 -341
- package/doc/zh/ActionPattern.md +0 -334
- package/doc/zh/Core.md +0 -256
- 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)。
|