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
package/doc/en/HTTP.md ADDED
@@ -0,0 +1,507 @@
1
+ # Lakutata HTTP
2
+
3
+ The HTTP adapters serve the HTTP actions of the controllers (`@GET`, `@POST`…) with Express or Fastify. This guide
4
+ covers what is particular to HTTP: the two adapters and their options, the routes, how a request becomes the input of
5
+ an action, the responses, the errors, and the features of the web framework itself. The controllers and their actions
6
+ are described in [Controllers](Controllers.md), the middlewares and the access control in
7
+ [Middlewares](Middlewares.md), and the starting and stopping of the adapters in [Entrypoint](Entrypoint.md).
8
+
9
+ ## 1. A first HTTP API
10
+
11
+ ```bash
12
+ npm install lakutata express
13
+ ```
14
+
15
+ ```typescript
16
+ import {Application, DTO, Exception} from 'lakutata'
17
+ import {buildEntrypoints, Controller, Result, type StatusResult} from 'lakutata/com/entrypoint'
18
+ import {DELETE, GET, POST} from 'lakutata/decorator/ctrl'
19
+ import {Expect} from 'lakutata/decorator/dto'
20
+ import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
21
+
22
+ class OrderIdDTO extends DTO {
23
+ //A route parameter is text: strict(false) converts '1' to 1
24
+ @Expect(DTO.Number().integer().strict(false).required())
25
+ public id: number
26
+ }
27
+
28
+ class NewOrderDTO extends DTO {
29
+ @Expect(DTO.String().required())
30
+ public product: string
31
+
32
+ @Expect(DTO.Number().integer().min(1).default(1))
33
+ public quantity: number
34
+ }
35
+
36
+ class OrderNotFoundException extends Exception {
37
+ public errno: string = 'E_ORDER_NOT_FOUND'
38
+
39
+ public statusCode: number = 404
40
+ }
41
+
42
+ type Order = { id: number, product: string, quantity: number }
43
+
44
+ const orders: Map<number, Order> = new Map()
45
+
46
+ class OrderController extends Controller {
47
+ @POST('/orders', NewOrderDTO)
48
+ public async create(input: NewOrderDTO): Promise<StatusResult> {
49
+ const order: Order = {id: orders.size + 1, product: input.product, quantity: input.quantity}
50
+ orders.set(order.id, order)
51
+ return Result.status(201, order, {Location: `/orders/${order.id}`})
52
+ }
53
+
54
+ @GET('/orders/:id', OrderIdDTO)
55
+ public async get(input: OrderIdDTO): Promise<Order> {
56
+ const order: Order | undefined = orders.get(input.id)
57
+ if (!order) throw new OrderNotFoundException('Order {id} not found', {id: input.id})
58
+ return order
59
+ }
60
+
61
+ @DELETE('/orders/:id', OrderIdDTO)
62
+ public async remove(input: OrderIdDTO): Promise<void> {
63
+ orders.delete(input.id)
64
+ }
65
+ }
66
+
67
+ Application.run(() => ({
68
+ id: 'orders.app',
69
+ name: 'Orders',
70
+ components: {
71
+ entrypoint: buildEntrypoints({
72
+ controllers: [OrderController],
73
+ http: expressHTTPEntrypoint({port: 8080})
74
+ })
75
+ },
76
+ bootstrap: ['entrypoint']
77
+ }))
78
+ ```
79
+
80
+ ```text
81
+ $ curl -i -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{"product": "lamp"}'
82
+ HTTP/1.1 201 Created
83
+ Location: /orders/1
84
+
85
+ {"id":1,"product":"lamp","quantity":1}
86
+ $ curl localhost:8080/orders/1
87
+ {"id":1,"product":"lamp","quantity":1}
88
+ $ curl -i -X DELETE localhost:8080/orders/1
89
+ HTTP/1.1 204 No Content
90
+ $ curl -i localhost:8080/orders/1
91
+ HTTP/1.1 404 Not Found
92
+
93
+ {"errno":"E_ORDER_NOT_FOUND","errMsg":"Order 1 not found"}
94
+ $ curl -i -X POST localhost:8080/orders -H 'Content-Type: application/json' -d '{}'
95
+ HTTP/1.1 400 Bad Request
96
+
97
+ {"errno":"E_INVALID_VALUE","errMsg":"\"product\" is required"}
98
+ ```
99
+
100
+ ## 2. Express or Fastify
101
+
102
+ Both adapters serve the same actions, with the same results:
103
+
104
+ | | Express | Fastify |
105
+ |---|---|---|
106
+ | Adapter | `expressHTTPEntrypoint()`, from `lakutata/adapter/express` | `fastifyHTTPEntrypoint()`, from `lakutata/adapter/fastify` |
107
+ | Install | `express` (4 or 5) | `fastify` (5) |
108
+
109
+ The options of `expressHTTPEntrypoint()`:
110
+
111
+ | Option | Default | Meaning |
112
+ |---|---|---|
113
+ | `port` | `80` | The port; `0` picks a free port. A port already in use fails the application's start |
114
+ | `host` | all the interfaces | The address to listen on, such as `'127.0.0.1'` for the local machine only |
115
+ | `name` | | The name of the adapter, to tell several adapters apart |
116
+ | `body` | `{limit: '1mb'}` | The size limit of the JSON and URL-encoded bodies (`'5mb'`, or a number of bytes); `false` parses no body |
117
+ | `server` | | The options of the Node.js HTTP server: `keepAliveTimeout`, `headersTimeout`, `requestTimeout`… |
118
+ | `app` | a new application | An existing Express application to add the actions to |
119
+ | `input` | the query and the body | Builds the input from the request (section 4) |
120
+ | `respond` | `JSONResponder` | Writes the results and the errors (section 6) |
121
+ | `middlewares` | | The middlewares of the actions of this adapter, run after those of the entrypoint |
122
+ | `before` | | `(app, context) => …`: adds the application's own middlewares and routes (section 7) |
123
+ | `fallback` | | Answers the requests outside the paths of the actions, such as the pages of a web UI (section 7) |
124
+ | `stop` | | Called when the adapter stops, once its server is closed |
125
+ | `heartbeat` | `15000` | How often a comment keeps the event streams alive, in milliseconds (section 5) |
126
+
127
+ `fastifyHTTPEntrypoint()` takes the same options, except `body`, `server` and `app`, replaced by `fastify`: a Fastify
128
+ instance, or the options of the instance to create (`bodyLimit`, 1 MiB by default; `trustProxy`; `connectionTimeout`,
129
+ `keepAliveTimeout`, `requestTimeout`; `logger`…).
130
+
131
+ Where they differ:
132
+
133
+ | | Express | Fastify |
134
+ |---|---|---|
135
+ | URL-encoded form bodies | Parsed | Refused with 415 |
136
+ | `this.context.request` | The Express request (`ip`, `get()`, `hostname`…) | The Node.js request |
137
+ | A wildcard route | `/files/*path`: the input gets `path: ['docs', 'a.txt']` | `/files/*`: the input gets `'*': 'docs/a.txt'` |
138
+ | Other HTTP methods than `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` | Accepted | Registered with `app.addHttpMethod()` in `before`; otherwise the start fails |
139
+ | A web UI as `fallback` | `staticUI()` (section 7) | A handler of your own |
140
+ | The `errno` of the errors of the framework | `E_UNKNOWN` | Its codes, such as `FST_ERR_CTP_BODY_TOO_LARGE` |
141
+ | A given application or instance, when the application stops | Left as it is | Closed |
142
+
143
+ When the application stops (see [Entrypoint](Entrypoint.md), section 4), the adapters stop accepting connections and end
144
+ the event streams; Fastify also answers 503 to the requests that still arrive on the open connections. The requests in
145
+ progress get the `shutdownTimeout` of the entrypoint (after it, they are answered with 503), then up to one second to
146
+ send their responses, before the remaining connections are closed, the WebSockets included.
147
+
148
+ ## 3. Routes
149
+
150
+ | Decorator | Methods |
151
+ |---|---|
152
+ | `@GET(route)`, `@POST`, `@PUT`, `@PATCH`, `@DELETE`, `@HEAD`, `@OPTIONS` | The method of its name |
153
+ | `@HTTPAction(route, method)` | One method (`'purge'`), or a list (`['get', 'post']`) |
154
+
155
+ Each of them then takes the DTO of the input and the options of the action, both optional (see
156
+ [Controllers](Controllers.md), section 3).
157
+
158
+ ```typescript
159
+ import {DTO} from 'lakutata'
160
+ import {Controller} from 'lakutata/com/entrypoint'
161
+ import {GET, HTTPAction, Prefix} from 'lakutata/decorator/ctrl'
162
+ import {Expect} from 'lakutata/decorator/dto'
163
+
164
+ class SearchDTO extends DTO {
165
+ @Expect(DTO.String().required())
166
+ public keyword: string
167
+ }
168
+
169
+ @Prefix({route: '/api'})
170
+ class LibraryController extends Controller {
171
+ //GET /api/search?keyword=lamp, or POST /api/search with the body {"keyword": "lamp"}
172
+ @HTTPAction('/search', ['get', 'post'], SearchDTO)
173
+ public async search(input: SearchDTO): Promise<string[]> {
174
+ return [input.keyword]
175
+ }
176
+
177
+ //GET /api/files/docs/guide.pdf with Express: input.path is ['docs', 'guide.pdf'] (with Fastify: '/files/*' and input['*'])
178
+ @GET('/files/*path')
179
+ public async file(input: { path: string[] }): Promise<string> {
180
+ return input.path.join('/')
181
+ }
182
+ }
183
+ ```
184
+
185
+ - A route uses the syntax of the web framework: `:id` is a parameter with both; the wildcards differ (section 2).
186
+ - `@Prefix({route})` puts a prefix before the routes of a controller (see [Controllers](Controllers.md), section 6).
187
+ - A `GET` action also answers the `HEAD` requests of its route, without a body, unless the route has a `HEAD` action.
188
+ - A request whose method has no action on its route is answered with 404 (`E_ROUTE_NOT_FOUND`), not 405. Nothing
189
+ answers `OPTIONS` by itself: the preflight requests of the browsers fail until a CORS middleware answers them
190
+ (section 7).
191
+
192
+ ## 4. The input
193
+
194
+ By default, the input of an action is the query, then the properties of the body over it, then the route parameters
195
+ over both:
196
+
197
+ ```text
198
+ POST /orders/7?id=1&note=a with the body {"id": 5, "note": "b"} → {id: '7', note: 'b'}
199
+ ```
200
+
201
+ The DTO of the action then validates it, and removes the keys it does not declare. `this.context.data` is the input
202
+ before the validation, without the route parameters.
203
+
204
+ - **Text.** The values of the query and the route parameters are strings: a schema of a number or a boolean needs
205
+ `.strict(false)` to accept `'42'` or `'true'`.
206
+ - **The query.** A key given several times is an array (`?tag=a&tag=b` → `tag: ['a', 'b']`). With Express 5 and
207
+ Fastify, the brackets do not nest: `?filter[status]=open` gives the key `'filter[status]'`.
208
+ - **JSON bodies.** An array gives no usable input (Express spreads its items under numbered keys, Fastify ignores it):
209
+ send a list inside an object (`{"items": [...]}`).
210
+ - **Forms.** Express parses the URL-encoded bodies, the brackets nested (`a[b]=1` → `a: {b: '1'}`); Fastify refuses them
211
+ with 415. Neither parses `multipart/form-data`, the file uploads (section 7).
212
+ - **Limits.** A body over the limit is refused with 413, malformed JSON with 400.
213
+
214
+ The `input` option builds the input from the request. For example, to give the actions the address of the client and
215
+ its language (only the DTOs that declare `ip` and `language` keep them):
216
+
217
+ ```typescript
218
+ import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
219
+ import type {Request} from 'express'
220
+
221
+ export const http = expressHTTPEntrypoint({
222
+ port: 8080,
223
+ input: (request: Request): Record<string, unknown> => ({
224
+ ...request.query,
225
+ ...request.body,
226
+ ip: request.ip,
227
+ language: request.get('accept-language')
228
+ })
229
+ })
230
+ ```
231
+
232
+ The route parameters are always merged over the result. With Express, an action can also read the request itself:
233
+ `(this.context as HTTPContext).request as unknown as Request` gives the Express request, with `ip`, `get()` and the
234
+ properties that its middlewares add. With Fastify, `this.context.request` is the Node.js request: give the action
235
+ `request.ip` through `input`.
236
+
237
+ ## 5. The response
238
+
239
+ - A value is sent as JSON; `undefined` is answered with 204.
240
+ - `Result.status(status, data?, headers?)` sends a status and headers with the data, or without data.
241
+ - `Result.file(source, options?)` sends a file: its path (relative to the working directory), a `Buffer` or a stream.
242
+ `filename` makes it a download; `contentType` sets its type, otherwise taken from the extension of its name; `etag`
243
+ sends `ETag` and answers 304 when the `If-None-Match` of the request matches it.
244
+ - `Result.events(producer)` sends Server-Sent Events.
245
+
246
+ ```typescript
247
+ import {Readable} from 'node:stream'
248
+ import {Controller, Result, type EventsResult, type FileResult, type StatusResult} from 'lakutata/com/entrypoint'
249
+ import {GET, POST} from 'lakutata/decorator/ctrl'
250
+
251
+ class ExportController extends Controller {
252
+ //A download
253
+ @GET('/exports/orders.csv')
254
+ public async orders(): Promise<FileResult> {
255
+ return Result.file(Readable.from(['id,product\n', '1,lamp\n']), {contentType: 'text/csv', filename: 'orders.csv'})
256
+ }
257
+
258
+ //The progress of a job, until it ends or the client leaves
259
+ @GET('/exports/progress')
260
+ public async progress(): Promise<EventsResult> {
261
+ return Result.events(async (send, signal: AbortSignal): Promise<void> => {
262
+ for (let percent: number = 0; percent <= 100 && !signal.aborted; percent += 25) {
263
+ send({percent: percent}, 'progress')
264
+ await new Promise((resolve: (value: unknown) => void): unknown => setTimeout(resolve, 1000))
265
+ }
266
+ })
267
+ }
268
+
269
+ //A redirection
270
+ @POST('/logout')
271
+ public async logout(): Promise<StatusResult> {
272
+ return Result.status(303, undefined, {Location: '/'})
273
+ }
274
+ }
275
+ ```
276
+
277
+ The events are written as the standard defines them: the data as JSON (a string as it is, one `data:` line per line),
278
+ with the event's id and name before it when they are given:
279
+
280
+ ```text
281
+ id: 1
282
+ event: tick
283
+ data: {"n":1}
284
+
285
+ data: line one
286
+ data: line two
287
+ ```
288
+
289
+ - A comment (`: ping`) is sent every `heartbeat` milliseconds, so that the proxies keep the connection open.
290
+ - The stream ends when the producer returns or fails; a failure sends nothing more, the headers being already sent.
291
+ `signal` is aborted when the client leaves or the application stops.
292
+ - The producer runs after the scope of the request is destroyed: it should use components, not the providers of the
293
+ request.
294
+
295
+ An action can also write the response itself through `this.context.response`, the Node.js response. Once the action has
296
+ sent the headers, the adapter writes neither its result nor its error. When the client leaves before the response,
297
+ `this.signal` is aborted, and nothing is written.
298
+
299
+ ## 6. Errors
300
+
301
+ An error is answered with a status and `{errno, errMsg}`:
302
+
303
+ - the status is the first client error (4xx) found in the `statusCode` or `status` of the error or of its causes
304
+ (`cause`), otherwise its own status when it is between 400 and 599, otherwise 500;
305
+ - `errno` and `errMsg` come from the error itself: its `errno`, otherwise its `code`, otherwise `E_UNKNOWN`; its
306
+ `errMsg`, otherwise its `message`. So `new Error('Lookup failed', {cause: notFound})` is answered with the 404 of its
307
+ cause, and `{"errno": "E_UNKNOWN", "errMsg": "Lookup failed"}`.
308
+
309
+ | Status | `errno` | When |
310
+ |---|---|---|
311
+ | 400 | `E_INVALID_VALUE` | The DTO refused the input |
312
+ | 400 | `E_UNKNOWN` (Express), `FST_ERR_CTP_INVALID_JSON_BODY` (Fastify) | The JSON of the body is malformed |
313
+ | 403 | `E_ACCESS_DENY` | The access control refused the request (see [Middlewares](Middlewares.md)) |
314
+ | 404 | `E_ROUTE_NOT_FOUND` | No action has this route and method |
315
+ | 413 | `E_UNKNOWN` (Express), `FST_ERR_CTP_BODY_TOO_LARGE` (Fastify) | The body is over the limit |
316
+ | 415 | `FST_ERR_CTP_INVALID_MEDIA_TYPE` | Fastify does not parse this type of body |
317
+ | 503 | `E_ENTRYPOINT_STOPPED` | The application is stopping |
318
+ | 500 | The error's | Any other error |
319
+
320
+ An exception of the application gives its own `errno` and `statusCode`, as `OrderNotFoundException` in section 1.
321
+
322
+ **The envelope.** `jsonEnvelope()` (Express) and `fastifyJSONEnvelope()` (Fastify) wrap every response in
323
+ `{errno, errMsg, data}`:
324
+
325
+ ```typescript
326
+ import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
327
+
328
+ export const http = expressHTTPEntrypoint({
329
+ port: 8080,
330
+ //{"errno": null, "errMsg": null, "data": {...}, "timestamp": 1792209357257}
331
+ respond: jsonEnvelope({actionErrorStatus: 200, extra: (): Record<string, unknown> => ({timestamp: Date.now()})})
332
+ })
333
+ ```
334
+
335
+ - A result is `{errno: null, errMsg: null, data}`, with the status 200 when there is no data (`data` is `null`).
336
+ - An error is `{errno, errMsg, data: null}`, with its status. `errorStatus` gives the status of every error, such as
337
+ 200 for the clients that read the errors from the envelope. `actionErrorStatus` gives the status of the errors of the
338
+ actions only (their validation, their access control, their exceptions): a route not found and the errors of the
339
+ middlewares of the web framework keep theirs.
340
+ - `extra(request)` adds fields to every response, after `errno`, `errMsg` and `data`, which they never replace.
341
+
342
+ **A responder of your own** is an object with two methods, given to `respond`: `success(response, data, request)` and
343
+ `error(response, error, request, origin)`. `origin` is `'action'` when the request reached an action, `'request'` when
344
+ it failed before (a middleware of the web framework, a route not found). `statusOfError()` and `describeError()`, from
345
+ the adapter's subpath, compute the status and the `{errno, errMsg}` described above. The files and the event streams do
346
+ not go through the responder.
347
+
348
+ ## 7. The features of the web framework
349
+
350
+ The `before` hook receives the Express application or the Fastify instance before the routes of the actions are added.
351
+ Its middlewares, plugins and routes come first: a route of its own takes precedence over an action with the same path.
352
+ Its second argument gives:
353
+
354
+ | Property | What it is |
355
+ |---|---|
356
+ | `module` | The module of the entrypoint, to get objects: `await module.getObject('db')` |
357
+ | `server` | The HTTP server, not listening yet: a WebSocket server can be attached to it |
358
+ | `routes` | The routes of the actions, each with its methods |
359
+ | `actionPrefixes`, `isActionPath(path)` | The first segments of the routes of the actions, and whether a path is under one of them |
360
+
361
+ A failure of `before` fails the application's start. With Express, the body parsers are added after `before`: its
362
+ middlewares do not see a parsed body.
363
+
364
+ ```typescript
365
+ import compression from 'compression'
366
+ import cors from 'cors'
367
+ import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
368
+ import type {Express, Request, Response} from 'express'
369
+
370
+ export const http = expressHTTPEntrypoint({
371
+ port: 8080,
372
+ before: (app: Express): void => {
373
+ //Behind a reverse proxy on the same machine: request.ip and request.protocol come from its X-Forwarded-* headers
374
+ app.set('trust proxy', 'loopback')
375
+ //Answers the preflight requests of the browsers, and adds the CORS headers to the responses
376
+ app.use(cors({origin: 'https://app.example.com'}))
377
+ app.use(compression())
378
+ app.get('/health', (_request: Request, response: Response): void => void response.send('ok'))
379
+ }
380
+ })
381
+ ```
382
+
383
+ With Fastify, register the plugins in an `async` hook (`await app.register(fastifyCors, {origin: …})`, from
384
+ `@fastify/cors`), and trust the proxy with `fastify: {trustProxy: true}`.
385
+
386
+ **HTTPS.** The adapters serve HTTP. Put a reverse proxy in front of the application (nginx, Caddy, a load balancer) to
387
+ serve HTTPS, and trust it as above.
388
+
389
+ ### Uploads
390
+
391
+ The adapters do not parse `multipart/form-data`. With Express, add a parser such as `multer` to the route in `before`,
392
+ and give its file to the input:
393
+
394
+ ```typescript
395
+ import multer from 'multer'
396
+ import {DTO} from 'lakutata'
397
+ import {Controller} from 'lakutata/com/entrypoint'
398
+ import {POST} from 'lakutata/decorator/ctrl'
399
+ import {Expect} from 'lakutata/decorator/dto'
400
+ import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
401
+ import type {Express, Request} from 'express'
402
+
403
+ class AvatarDTO extends DTO {
404
+ @Expect(DTO.String().required())
405
+ public user: string
406
+
407
+ //The file of multer: {originalname, mimetype, size, buffer…}
408
+ @Expect(DTO.Object().required())
409
+ public file: Express.Multer.File
410
+ }
411
+
412
+ class AvatarController extends Controller {
413
+ @POST('/avatars', AvatarDTO)
414
+ public async upload(input: AvatarDTO): Promise<{ name: string, size: number }> {
415
+ return {name: input.file.originalname, size: input.file.buffer.length}
416
+ }
417
+ }
418
+
419
+ const upload: multer.Multer = multer({storage: multer.memoryStorage(), limits: {fileSize: 5 * 1024 * 1024}})
420
+
421
+ export const http = expressHTTPEntrypoint({
422
+ port: 8080,
423
+ before: (app: Express): void => {
424
+ //Parses the form of POST /avatars, then the request goes on to the action
425
+ app.post('/avatars', upload.single('file'))
426
+ },
427
+ input: (request: Request): Record<string, unknown> => ({...request.query, ...request.body, file: request.file})
428
+ })
429
+ ```
430
+
431
+ The errors of multer, such as a file over its limit (`LIMIT_FILE_SIZE`), have no status: they are answered with 500.
432
+
433
+ With Fastify, register `@fastify/multipart` with `attachFieldsToBody: 'keyValues'` in `before`: the fields and the
434
+ files are then properties of the body, and so of the input. A file is a `Buffer` (declare it `DTO.Binary()`), without
435
+ its name; a file over the limit is refused with 413.
436
+
437
+ ### A web UI
438
+
439
+ `fallback` answers the requests outside the paths of the actions (`isActionPath()` above): with Express, `staticUI()`
440
+ serves a built single-page application, such as the `dist` directory of a Vite build:
441
+
442
+ ```typescript
443
+ import {expressHTTPEntrypoint, staticUI} from 'lakutata/adapter/express'
444
+
445
+ export const http = expressHTTPEntrypoint({
446
+ port: 8080,
447
+ fallback: staticUI('./ui/dist')
448
+ })
449
+ ```
450
+
451
+ - A file of the directory is sent as it is. The files with a content hash in their name (`index-B2x9fQ1a.js`) are
452
+ cached by the browsers for a year (`assetsMaxAge`); the other files are checked again on each request.
453
+ - A `GET` request for a page (accepting HTML, its path without an extension) that matches no file is answered with the
454
+ `index.html` of the application, whose router handles the path. `spa: false` turns this off, and `index` names
455
+ another file.
456
+ - The other requests that match no file, such as a missing script, are answered with 404.
457
+ - The paths under the routes of the actions (`/api/…`) never reach the fallback: a request there that matches no action
458
+ is answered with 404.
459
+
460
+ With Fastify, `fallback` is a handler of your own, `(request, reply) => …`, which receives every request outside the
461
+ paths of the actions.
462
+
463
+ ## 8. From lakutata 2.x
464
+
465
+ An HTTP entrypoint function of 2.x (`buildHTTPEntrypoint`) keeps working: it is called in the startup of the entrypoint
466
+ (the objects it gets, such as an authenticator injecting the entrypoint, are available) and awaited, so the application
467
+ launches once its server listens. To move it to `expressHTTPEntrypoint`:
468
+
469
+ - the middlewares and the routes of the application (CORS, compression, a health check, the authenticator, a web UI) go
470
+ into its `before` hook, which gets the objects with `module.getObject()`; the body parsers are its `body` option
471
+ (`{limit: '100mb'}`); the `keepAliveTimeout` and `headersTimeout` of the server are its `server` option;
472
+ - the input built from the request (the query, the body, a value of the session) is its `input` option;
473
+ - the `{errno, errMsg, data}` envelope is `jsonEnvelope()`. The applications of 2.x usually answered the errors of the
474
+ actions with 200, and the errors of their Express middlewares (`next(error)`: the 401 of an authenticator) with their
475
+ status: `jsonEnvelope({actionErrorStatus: 200})` does the same. `errorStatus: 200` would answer the 401 with 200 too,
476
+ and the clients that go to the login page on a 401 would miss it;
477
+ - the other fields of the envelope of the application (`timestamp`, `nonce`, a request id) come from its `extra` option,
478
+ called for each response, results and errors alike. No responder of your own is needed to keep them.
479
+
480
+ ```typescript
481
+ import {buildEntrypoints} from 'lakutata/com/entrypoint'
482
+ import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
483
+ import type {Express, NextFunction, Request, Response} from 'express'
484
+
485
+ class Unauthorized extends Error {
486
+ public readonly errno: string = 'E_UNAUTHORIZED'
487
+
488
+ public readonly statusCode: number = 401
489
+ }
490
+
491
+ export const entrypoint = buildEntrypoints({
492
+ controllers: [],
493
+ http: expressHTTPEntrypoint({
494
+ port: 3000,
495
+ body: {limit: '100mb'},
496
+ before: async (app: Express): Promise<void> => {
497
+ app.get('/health', (_request: Request, response: Response): unknown => response.json({status: 'ok'}))
498
+ //The authenticator of the requests: its errors keep their status (401)
499
+ app.use((request: Request, _response: Response, next: NextFunction): void => request.headers.authorization ? next() : next(new Unauthorized('Not logged in')))
500
+ },
501
+ //The errors of the actions answered with 200 in the envelope, as in 2.x, with its timestamp
502
+ respond: jsonEnvelope({actionErrorStatus: 200, extra: (): Record<string, unknown> => ({timestamp: Date.now()})})
503
+ })
504
+ })
505
+ ```
506
+
507
+ With Fastify, `fastifyJSONEnvelope()` takes the same options, and the authenticator is a hook of Fastify.
@@ -2,9 +2,9 @@
2
2
 
3
3
  `lakutata/utils` (package `@lakutata/utils`) exports the helpers the framework and its applications share: strings,
4
4
  objects, arrays and sets, hashes and encodings, UUIDs, IP addresses, globs, statistics, and `Time`
5
- (see [Time](Time.md)). They are plain functions and classes with no dependency: import them where they are needed,
6
- inside an application or not, without registration. The README of `@lakutata/utils` and the typings of each helper give
7
- the details and the errors they throw.
5
+ (see [Time](Time.md)). They are plain functions and classes with no dependencies: import them wherever they are needed,
6
+ inside an application or not; nothing needs to be registered. The README of `@lakutata/utils` and the type declarations
7
+ of each helper give the details, including the errors they throw.
8
8
 
9
9
  ```typescript
10
10
  import {Application, Component, Time} from 'lakutata'
@@ -52,7 +52,7 @@ Application
52
52
  | Helper | What it does |
53
53
  |---|---|
54
54
  | `ObjectPath.get()`, `.set()`, `.has()` | Reads, sets and tests a nested property by its path (`'a.b[0].c'`) |
55
- | `cloneDeep(value)` | Deep copy, the classes, collections and circular references kept |
55
+ | `cloneDeep(value)` | A deep copy that keeps classes, collections and circular references |
56
56
  | `objectHash(value, options?)` | A hash of a value by its content, whatever the order of its keys |
57
57
  | `sortArray(array, ...options)` | Sorts an array in place, by its items or their properties (`by`, `order`, `customOrders`, `computed`) |
58
58
  | `sortKeys(object, options?)` | A copy of a plain object with its keys sorted by a comparison (`deep`, `compare`) |
@@ -97,9 +97,9 @@ console.log(uniqueArray([3, 1, 3, 2]), Object.keys(sortObject({b: 1, a: 2})))
97
97
  | `MessagePack` | Serializes values to and from MessagePack bytes or strings |
98
98
  | `UUID` | Generates (`v1`, `v3`, `v4`, `v5`, `v6`), validates, parses and stringifies UUIDs |
99
99
  | `URLBuilder` | Parses a URL of any scheme into its attributes, or builds one from them |
100
- | `hexToUnsigned`, `unsignedToHex(value, bits = 32)` | Hexadecimal and unsigned integers (8 to 128 bits) |
101
- | `hexToSigned`, `signedToHex(value, bits = 32)` | Hexadecimal and signed integers (two's complement) |
102
- | `hexToIEEE754`, `ieee754ToHex` | Hexadecimal and IEEE 754 floating point numbers |
100
+ | `hexToUnsigned`, `unsignedToHex(value, bits = 32)` | Converts between hexadecimal and unsigned integers (8 to 128 bits) |
101
+ | `hexToSigned`, `signedToHex(value, bits = 32)` | Converts between hexadecimal and signed integers (two's complement) |
102
+ | `hexToIEEE754`, `ieee754ToHex` | Converts between hexadecimal and IEEE 754 floating point numbers |
103
103
  | `IP`, `IPv4`, `IPv6` | Validate, convert and compare IP addresses and CIDR blocks |
104
104
 
105
105
  ```typescript
@@ -122,11 +122,11 @@ console.log(UUID.validate(UUID.v4()))
122
122
  |---|---|
123
123
  | `delay(ms)` | Waits for a number of milliseconds |
124
124
  | `gracefulExit` | Exits the process once cleanup functions have run |
125
- | `pathExists(path)`, `isPath(text)` | Whether a file or directory exists; whether a string is written as a path |
125
+ | `pathExists(path)`, `isPath(text)` | Whether a file or directory exists; whether a string looks like a path |
126
126
  | `Glob(patterns, options?)`, `GlobSync`, `isGlobString` | The files matching glob patterns; whether a string is a glob pattern |
127
127
  | `isPromise`, `isPromiseLike`, `isAbortError` | Whether a value is a promise or a thenable; whether an error is an abort error of Node.js |
128
128
  | `isSymbol`, `isNativeFunction`, `isHTML`, `isXML` | Type guards |
129
- | `As<T>(value)`, `DevNull(...args)` | A cast for the type checker only; a function ignoring its arguments |
129
+ | `As<T>(value)`, `DevNull(...args)` | A cast for the type checker only; a function that ignores its arguments |
130
130
 
131
131
  ```typescript
132
132
  import {delay, Glob, pathExists, Statistics} from 'lakutata/utils'
@@ -141,5 +141,5 @@ console.log(Statistics.median(latencies), Statistics.quantile(latencies, 0.9))
141
141
  //13.5 90
142
142
  ```
143
143
 
144
- The guards (`Is*`) return `false` instead of throwing; the other helpers throw the standard JavaScript errors
145
- (`RangeError`, `TypeError`), and `templating()` a `MissingValueError`.
144
+ The type guards (`is…`) return `false` instead of throwing; the other helpers throw standard JavaScript errors
145
+ (`RangeError`, `TypeError`), and `templating()` throws a `MissingValueError`.