lakutata 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +50 -30
  2. package/README.zh-CN.md +130 -0
  3. package/dist/cjs/exports/QueueComponent.d.ts +1 -0
  4. package/dist/cjs/exports/QueueComponent.js +20 -0
  5. package/dist/esm/exports/QueueComponent.js +1 -0
  6. package/dist/types/exports/QueueComponent.d.ts +1 -0
  7. package/doc/en/Application.md +107 -0
  8. package/doc/en/CLI.md +274 -0
  9. package/doc/en/Cache.md +379 -0
  10. package/doc/en/Command.md +110 -0
  11. package/doc/en/ContainerdIntegration.md +17 -16
  12. package/doc/en/Controllers.md +256 -0
  13. package/doc/en/DBus.md +291 -0
  14. package/doc/en/DTOValidation.md +72 -43
  15. package/doc/en/Database.md +188 -0
  16. package/doc/en/DatabaseDrivers.md +287 -0
  17. package/doc/en/Decorator.md +52 -40
  18. package/doc/en/DockerIntegration.md +7 -6
  19. package/doc/en/Entities.md +240 -0
  20. package/doc/en/Entrypoint.md +216 -0
  21. package/doc/en/Exceptions.md +174 -0
  22. package/doc/en/GRPC.md +251 -0
  23. package/doc/en/GettingStarted.md +165 -0
  24. package/doc/en/HTTP.md +507 -0
  25. package/doc/en/HelperFunctions.md +11 -11
  26. package/doc/en/I18n.md +119 -115
  27. package/doc/en/Injection.md +144 -0
  28. package/doc/en/Installers.md +280 -0
  29. package/doc/en/Logger.md +206 -0
  30. package/doc/en/MCP.md +293 -0
  31. package/doc/en/Middlewares.md +213 -0
  32. package/doc/en/Migrations.md +202 -0
  33. package/doc/en/MongoEmbedded.md +149 -0
  34. package/doc/en/Monitor.md +164 -0
  35. package/doc/en/Mutex.md +27 -25
  36. package/doc/en/NATS.md +246 -0
  37. package/doc/en/NativeAddons.md +212 -0
  38. package/doc/en/Objects.md +143 -0
  39. package/doc/en/Overview.md +62 -85
  40. package/doc/en/Packaging.md +193 -0
  41. package/doc/en/ProjectStructure.md +124 -0
  42. package/doc/en/Queue.md +269 -0
  43. package/doc/en/Schedules.md +217 -0
  44. package/doc/en/Security.md +10 -9
  45. package/doc/en/ServiceCalls.md +504 -0
  46. package/doc/en/Time.md +19 -20
  47. package/doc/en/UpgradingFrom1.md +435 -0
  48. package/doc/en/UpgradingFrom2.md +315 -0
  49. package/doc/en/Workspaces.md +189 -0
  50. package/doc/en/examples/BackgroundJobs.md +677 -0
  51. package/doc/en/examples/CommandLineTool.md +664 -0
  52. package/doc/en/examples/DesktopApp.md +809 -0
  53. package/doc/en/examples/DeviceService.md +607 -0
  54. package/doc/en/examples/MCPServer.md +653 -0
  55. package/doc/en/examples/README.md +16 -0
  56. package/doc/en/examples/RestAPI.md +490 -0
  57. package/doc/en/examples/ServiceCalls.md +829 -0
  58. package/doc/en/examples/WebApp.md +568 -0
  59. package/doc/zh/Application.md +75 -0
  60. package/doc/zh/CLI.md +250 -0
  61. package/doc/zh/Cache.md +328 -0
  62. package/doc/zh/Command.md +78 -0
  63. package/doc/zh/ContainerdIntegration.md +9 -9
  64. package/doc/zh/Controllers.md +228 -0
  65. package/doc/zh/DBus.md +257 -0
  66. package/doc/zh/DTOValidation.md +49 -19
  67. package/doc/zh/Database.md +175 -0
  68. package/doc/zh/DatabaseDrivers.md +261 -0
  69. package/doc/zh/Decorator.md +25 -18
  70. package/doc/zh/DockerIntegration.md +6 -6
  71. package/doc/zh/Entities.md +224 -0
  72. package/doc/zh/Entrypoint.md +193 -0
  73. package/doc/zh/Exceptions.md +158 -0
  74. package/doc/zh/GRPC.md +226 -0
  75. package/doc/zh/GettingStarted.md +148 -0
  76. package/doc/zh/HTTP.md +445 -0
  77. package/doc/zh/HelperFunctions.md +2 -2
  78. package/doc/zh/I18n.md +26 -26
  79. package/doc/zh/Injection.md +129 -0
  80. package/doc/zh/Installers.md +232 -0
  81. package/doc/zh/Logger.md +188 -0
  82. package/doc/zh/MCP.md +265 -0
  83. package/doc/zh/Middlewares.md +198 -0
  84. package/doc/zh/Migrations.md +178 -0
  85. package/doc/zh/MongoEmbedded.md +118 -0
  86. package/doc/zh/Monitor.md +145 -0
  87. package/doc/zh/Mutex.md +10 -10
  88. package/doc/zh/NATS.md +207 -0
  89. package/doc/zh/NativeAddons.md +180 -0
  90. package/doc/zh/Objects.md +119 -0
  91. package/doc/zh/Overview.md +46 -71
  92. package/doc/zh/Packaging.md +154 -0
  93. package/doc/zh/ProjectStructure.md +102 -0
  94. package/doc/zh/Queue.md +214 -0
  95. package/doc/zh/Schedules.md +193 -0
  96. package/doc/zh/Security.md +6 -6
  97. package/doc/zh/ServiceCalls.md +459 -0
  98. package/doc/zh/Time.md +2 -2
  99. package/doc/zh/UpgradingFrom1.md +347 -0
  100. package/doc/zh/UpgradingFrom2.md +230 -0
  101. package/doc/zh/Workspaces.md +150 -0
  102. package/doc/zh/examples/BackgroundJobs.md +618 -0
  103. package/doc/zh/examples/CommandLineTool.md +618 -0
  104. package/doc/zh/examples/DesktopApp.md +748 -0
  105. package/doc/zh/examples/DeviceService.md +546 -0
  106. package/doc/zh/examples/MCPServer.md +604 -0
  107. package/doc/zh/examples/README.md +14 -0
  108. package/doc/zh/examples/RestAPI.md +458 -0
  109. package/doc/zh/examples/ServiceCalls.md +765 -0
  110. package/doc/zh/examples/WebApp.md +520 -0
  111. package/package.json +46 -17
  112. package/doc/en/ActionPattern.md +0 -408
  113. package/doc/en/Core.md +0 -312
  114. package/doc/en/ORMIntegration.md +0 -341
  115. package/doc/zh/ActionPattern.md +0 -334
  116. package/doc/zh/Core.md +0 -256
  117. package/doc/zh/ORMIntegration.md +0 -319
@@ -0,0 +1,504 @@
1
+ # Lakutata Service Calls
2
+
3
+ A service action (`@ServiceAction`) is called with a message rather than a URL: an object whose keys select the action,
4
+ its **pattern**, and whose other keys are the input. The same actions can be served over several transports at once:
5
+ plain HTTP, Socket.IO, JSON-RPC 2.0 (over HTTP, WebSocket or the standard input and output) and the IPC of Electron, as
6
+ this guide describes, and gRPC and NATS, which have their own guides. The controllers and their actions are described
7
+ in [Controllers](Controllers.md), the entrypoint and its adapters in [Entrypoint](Entrypoint.md).
8
+
9
+ ## 1. A first service
10
+
11
+ ```typescript
12
+ import {Application, DTO, Exception} from 'lakutata'
13
+ import {buildEntrypoints, Controller, httpServiceEntrypoint} from 'lakutata/com/entrypoint'
14
+ import {ServiceAction} from 'lakutata/decorator/ctrl'
15
+ import {Expect} from 'lakutata/decorator/dto'
16
+
17
+ class NewOrderDTO extends DTO {
18
+ @Expect(DTO.String().required())
19
+ public product: string
20
+ }
21
+
22
+ class OrderIdDTO extends DTO {
23
+ @Expect(DTO.Number().integer().required())
24
+ public id: number
25
+ }
26
+
27
+ class OrderNotFoundException extends Exception {
28
+ public errno: string = 'E_ORDER_NOT_FOUND'
29
+
30
+ public statusCode: number = 404
31
+ }
32
+
33
+ type Order = { id: number, product: string }
34
+
35
+ const orders: Map<number, Order> = new Map()
36
+
37
+ class OrderController extends Controller {
38
+ //The message {resource: 'order', act: 'create', product: 'lamp'}: the input is {product: 'lamp'}
39
+ @ServiceAction({resource: 'order', act: 'create'}, NewOrderDTO)
40
+ public async create(input: NewOrderDTO): Promise<Order> {
41
+ const order: Order = {id: orders.size + 1, product: input.product}
42
+ orders.set(order.id, order)
43
+ return order
44
+ }
45
+
46
+ @ServiceAction({resource: 'order', act: 'get'}, OrderIdDTO)
47
+ public async get(input: OrderIdDTO): Promise<Order> {
48
+ const order: Order | undefined = orders.get(input.id)
49
+ if (!order) throw new OrderNotFoundException('Order {id} not found', {id: input.id})
50
+ return order
51
+ }
52
+ }
53
+
54
+ Application.run(() => ({
55
+ id: 'orders.app',
56
+ name: 'Orders',
57
+ components: {
58
+ entrypoint: buildEntrypoints({
59
+ controllers: [OrderController],
60
+ //POST http://localhost:3000/ with the message as its JSON body
61
+ service: httpServiceEntrypoint({port: 3000})
62
+ })
63
+ },
64
+ bootstrap: ['entrypoint']
65
+ }))
66
+ ```
67
+
68
+ ```text
69
+ $ curl localhost:3000 -H 'Content-Type: application/json' -d '{"resource": "order", "act": "create", "product": "lamp"}'
70
+ {"success":true,"error":null,"payload":{"id":1,"product":"lamp"}}
71
+ $ curl localhost:3000 -H 'Content-Type: application/json' -d '{"resource": "order", "act": "get", "id": 1}'
72
+ {"success":true,"error":null,"payload":{"id":1,"product":"lamp"}}
73
+ $ curl localhost:3000 -H 'Content-Type: application/json' -d '{"resource": "order", "act": "get", "id": 9}'
74
+ {"success":false,"error":{…,"message":"Order 9 not found","errMsg":"Order 9 not found","err":"OrderNotFoundException","errno":"E_ORDER_NOT_FOUND"},"payload":null}
75
+ ```
76
+
77
+ The error carries all the properties of the exception (section 3).
78
+
79
+ ## 2. Patterns
80
+
81
+ A message matches a pattern when it holds the keys of the pattern with the same values. The keys of the pattern are
82
+ then removed from the message, and the rest is the input of the action. With the actions below:
83
+
84
+ ```typescript
85
+ import {Controller} from 'lakutata/com/entrypoint'
86
+ import {ServiceAction} from 'lakutata/decorator/ctrl'
87
+
88
+ class OrderController extends Controller {
89
+ @ServiceAction({resource: 'order', act: 'get'})
90
+ public async get(input: Record<string, unknown>): Promise<unknown> {
91
+ return input
92
+ }
93
+
94
+ //The other messages of the resource "order"
95
+ @ServiceAction({resource: 'order'})
96
+ public async other(input: Record<string, unknown>): Promise<unknown> {
97
+ return input
98
+ }
99
+
100
+ @ServiceAction({resource: 'order', act: 'audit.*'})
101
+ public async audit(input: Record<string, unknown>): Promise<unknown> {
102
+ return input
103
+ }
104
+
105
+ @ServiceAction({res: {name: 'user'}, act: 'get'})
106
+ public async user(input: Record<string, unknown>): Promise<unknown> {
107
+ return input
108
+ }
109
+
110
+ @ServiceAction({version: 2, act: 'info'})
111
+ public async info(): Promise<string> {
112
+ return 'v2'
113
+ }
114
+ }
115
+ ```
116
+
117
+ | Message | Action | Input |
118
+ |---|---|---|
119
+ | `{resource: 'order', act: 'get', id: 1}` | `get` | `{id: 1}` |
120
+ | `{resource: 'order', act: 'list', page: 1}` | `other` | `{act: 'list', page: 1}` |
121
+ | `{resource: 'order', act: 'audit.read', who: 'ada'}` | `audit` | `{who: 'ada'}` |
122
+ | `{res: {name: 'user', extra: 1}, act: 'get', q: 1}` | `user` | `{res: {extra: 1}, q: 1}` |
123
+ | `{version: '2', act: 'info'}` | `info` | `{}` |
124
+ | `{resource: 'invoice'}` | None: `ControllerActionNotFoundException` | |
125
+
126
+ - When several patterns match, the most specific one wins: the one with the most keys.
127
+ - A string value of a pattern may be a glob: `*` matches any characters, `?` one character.
128
+ - The values are compared as text: the pattern `{version: 2}` matches `version: '2'`.
129
+ - A pattern may nest objects, two levels at most; their keys are compared one by one.
130
+ - `@Prefix({pattern})` merges a pattern into the patterns of all the actions of a controller (see
131
+ [Controllers](Controllers.md), section 6).
132
+ - A message that no pattern matches fails with `ControllerActionNotFoundException` (`E_CONTROLLER_ACTION_NOT_FOUND`).
133
+ - A message is JSON, so its values keep their types: unlike the query of an HTTP request, `"2"` is not converted for a
134
+ schema that expects a number.
135
+
136
+ ## 3. The response
137
+
138
+ The HTTP service, Socket.IO and Electron answer with an envelope; JSON-RPC answers with its own responses (section 4).
139
+
140
+ ```text
141
+ {"success": true, "error": null, "payload": <the result>}
142
+ {"success": false, "error": <the error>, "payload": null}
143
+ ```
144
+
145
+ - An action that returns nothing gives no `payload`: `{"success": true, "error": null}`.
146
+ - `error` is a copy of all the own properties of the error: `message`, `stack`, `errno`, `errMsg`, `statusCode`, `err`
147
+ (the name of its class), `appId`, `appName`, and those of the exception's own class. The client gets the whole error
148
+ as the action threw it.
149
+ - `Result.status(status, data)` sends its data. A file or an event stream (`Result.file()`, `Result.events()`) cannot
150
+ be sent: the call fails.
151
+
152
+ The applications that call a service read the envelope through a proxy: `HTTPServiceProxy` for the HTTP service
153
+ (section 4), and the proxies of the NATS and gRPC transports. A proxy returns the `payload`, or throws a
154
+ `ServiceInvokeException` that carries the properties of the remote error. Other clients, such as the pages of a
155
+ browser, read the envelope themselves, for example:
156
+
157
+ ```typescript
158
+ type Envelope<T> = { success: boolean, error: { message?: string, errno?: string } | null, payload: T }
159
+
160
+ export async function call<T>(message: Record<string, unknown>): Promise<T> {
161
+ const response: Response = await fetch('http://localhost:3000/', {
162
+ method: 'POST',
163
+ headers: {'Content-Type': 'application/json'},
164
+ body: JSON.stringify(message)
165
+ })
166
+ const envelope: Envelope<T> = await response.json() as Envelope<T>
167
+ //The properties of the error (errno, statusCode…) copied onto an Error
168
+ if (!envelope.success) throw Object.assign(new Error(envelope.error?.message ?? 'The call failed'), envelope.error)
169
+ return envelope.payload
170
+ }
171
+ ```
172
+
173
+ ## 4. The transports
174
+
175
+ | Adapter | Imported from | To install | A request is | Events pushed |
176
+ |---|---|---|---|---|
177
+ | `httpServiceEntrypoint()` | `lakutata/com/entrypoint` | | A `POST` of the message as JSON | Server-Sent Events |
178
+ | `socketIOServiceEntrypoint()` | `lakutata/adapter/socket.io` | `socket.io` (`socket.io-client` on the clients) | An emit with an acknowledgement | Socket.IO events |
179
+ | `jsonRPCServiceEntrypoint()` | `lakutata/adapter/jsonrpc` | `ws`, for WebSocket | A JSON-RPC 2.0 request | Notifications (WebSocket, stdio) |
180
+ | `electronIPCServiceEntrypoint()` | `lakutata/adapter/electron` | (Electron) | `ipcRenderer.invoke()` | IPC messages |
181
+ | `grpcServiceEntrypoint()` | `@lakutata/grpc/service` | `@lakutata/grpc` | See [GRPC](GRPC.md) | gRPC streams |
182
+ | `natsServiceEntrypoint()` | `@lakutata/nats` | `@lakutata/nats` | See [NATS](NATS.md) | NATS messages |
183
+
184
+ Each adapter also takes `name`, to tell several adapters apart, and `middlewares`, run after those of the entrypoint
185
+ (see [Middlewares](Middlewares.md)).
186
+
187
+ ### HTTP service
188
+
189
+ `httpServiceEntrypoint()` serves the actions over plain HTTP: it suits the tools (curl, Postman), the scripts, and the
190
+ pages of a web UI during its development. The Electron templates add it in development for that.
191
+
192
+ | Option | Default | Meaning |
193
+ |---|---|---|
194
+ | `port` | `3000` | The port; `0` picks a free port |
195
+ | `host` | all the interfaces | The address to listen on |
196
+ | `path` | `'/'` | The path of the requests; the events are under it, at `<path>/events` |
197
+ | `limit` | `10485760` (10 MiB) | The largest body, in bytes |
198
+ | `listening` | | `(server) => …`, called once the server listens |
199
+
200
+ - A request is a `POST` to `path`, its body the message as JSON. It is answered with 200 and the envelope, whatever the
201
+ result of the action. A body that is not JSON is answered with 400, a body over `limit` with 413, another method or
202
+ path with 404; these responses carry an envelope too.
203
+ - The CORS headers allow any origin, the methods `GET`, `POST` and `OPTIONS`, and any request header, such as
204
+ `Authorization`.
205
+ - The client gives its id in the `id` header, or in the `id` query parameter (section 5).
206
+ - `GET <path>/events` streams the events pushed to the client, as Server-Sent Events. The first event, `client`, gives
207
+ the id of the client (a random one when the request gives none); then each event has its name, and the JSON array of
208
+ its arguments as its data. A comment keeps the stream alive every 15 seconds.
209
+
210
+ ```text
211
+ event: client
212
+ data: ["client-1"]
213
+
214
+ event: progress
215
+ data: [50,"half"]
216
+ ```
217
+
218
+ In a browser, `EventSource` reads them:
219
+
220
+ ```typescript
221
+ const events: EventSource = new EventSource('http://localhost:3000/events?id=client-1')
222
+ events.addEventListener('progress', (event: MessageEvent): void => console.log(JSON.parse(event.data)))
223
+ ```
224
+
225
+ Another application calls the service through an `HTTPServiceProxy`, a component registered with
226
+ `buildHTTPServiceProxy()`. Here it calls the orders service of section 1:
227
+
228
+ ```typescript
229
+ import {Application} from 'lakutata'
230
+ import {buildHTTPServiceProxy, type HTTPServiceProxy, ServiceInvokeException} from 'lakutata/com/entrypoint'
231
+
232
+ Application
233
+ .run(() => ({
234
+ id: 'shop.app',
235
+ name: 'Shop',
236
+ components: {
237
+ orders: buildHTTPServiceProxy({url: 'http://localhost:3000/'})
238
+ }
239
+ }))
240
+ .onLaunched(async (app: Application): Promise<void> => {
241
+ const orders: HTTPServiceProxy = await app.getObject<HTTPServiceProxy>('orders')
242
+ //{id: 1, product: 'lamp'}
243
+ console.log(await orders.invoke({resource: 'order', act: 'create', product: 'lamp'}))
244
+ try {
245
+ //5 seconds at most
246
+ await orders.invoke({resource: 'order', act: 'get', id: 9}, 5000)
247
+ } catch (error) {
248
+ //The error of the action, with its properties: E_ORDER_NOT_FOUND
249
+ if (error instanceof ServiceInvokeException) console.log(error.errno)
250
+ }
251
+ })
252
+ ```
253
+
254
+ | Option | Default | Meaning |
255
+ |---|---|---|
256
+ | `url` | | The address of the service, with the `path` of its adapter |
257
+ | `headers` | | The headers sent with each call and with the event stream, such as `Authorization` |
258
+ | `clientId` | a random UUID | The id of this client (section 5) |
259
+ | `reconnectDelay` | `1000` | The delay, in milliseconds, before the event stream is opened again |
260
+
261
+ - `invoke(message, timeout)` posts the message and returns the `payload`. The timeout is 10 minutes by default; when it
262
+ passes, the call is abandoned and the service aborts the signal of the action.
263
+ - An action that fails throws a `ServiceInvokeException` with the properties of its error (section 3). A service that
264
+ gives no response throws a `ServiceUnavailableException` (`E_SERVICE_UNAVAILABLE`, status 503): it cannot be
265
+ reached, it does not answer within the timeout, or its answer is not an envelope (the error page of a proxy in front
266
+ of it).
267
+ - `on(event, listener)`, `once()` and `off()` listen to the events that the service pushes to this client. The first
268
+ listener opens the event stream. When the stream drops, it is opened again after `reconnectDelay`; the events pushed
269
+ in the meantime are lost.
270
+
271
+ ### Socket.IO
272
+
273
+ `socketIOServiceEntrypoint()` serves the actions over Socket.IO: it suits the browsers and the clients that stay
274
+ connected.
275
+
276
+ | Option | Default | Meaning |
277
+ |---|---|---|
278
+ | `port` | `3000` | The port of its own server; `0` picks a free port |
279
+ | `host` | all the interfaces | The address of its own server |
280
+ | `event` | `'message'` | The event of the requests |
281
+ | `namespace` | `'/'` | The namespace of the requests |
282
+ | `options` | | The options of the Socket.IO server: `cors`, `path`, `maxHttpBufferSize`… |
283
+ | `server` | | An HTTP server to share, or a function returning it (below) |
284
+ | `io` | | An existing Socket.IO server, used as it is |
285
+ | `listening` | | `(io, server) => …`, called once the adapter is ready |
286
+
287
+ ```typescript
288
+ import {io, type Socket} from 'socket.io-client'
289
+
290
+ //Identified as client-1 (section 5)
291
+ const socket: Socket = io('http://localhost:3000', {auth: {id: 'client-1'}})
292
+ socket.on('progress', (percent: number): void => console.log(`${percent}%`))
293
+ //{success: true, error: null, payload: {id: 1, product: 'lamp'}}, or an error after 5 seconds without an answer
294
+ console.log(await socket.timeout(5000).emitWithAck('message', {resource: 'order', act: 'get', id: 1}))
295
+ ```
296
+
297
+ - A request is an emit of `event` with the message, and a callback that receives the envelope. An emit without a
298
+ callback still runs the action.
299
+ - The browsers of another origin need `options: {cors: {origin: …}}`.
300
+ - `socketOfContext(this.context)`, from `lakutata/adapter/socket.io`, gives the socket of a request, to join it to a
301
+ room for example.
302
+
303
+ Socket.IO can share the port of the HTTP adapter: give it the server that the `before` hook of `expressHTTPEntrypoint`
304
+ receives. The adapters start in the order of the options, so declare `http` before `service`:
305
+
306
+ ```typescript
307
+ import type {Server} from 'node:http'
308
+ import {buildEntrypoints} from 'lakutata/com/entrypoint'
309
+ import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
310
+ import {socketIOServiceEntrypoint} from 'lakutata/adapter/socket.io'
311
+
312
+ let httpServer: Server | undefined
313
+
314
+ export const entrypoint = buildEntrypoints({
315
+ controllers: [],
316
+ http: expressHTTPEntrypoint({port: 8080, before: (_app, context): void => void (httpServer = context.server)}),
317
+ //The HTTP actions and Socket.IO on the port 8080
318
+ service: socketIOServiceEntrypoint({server: (): Server => httpServer!})
319
+ })
320
+ ```
321
+
322
+ When it stops, the adapter disconnects the sockets and leaves the shared server to its owner.
323
+
324
+ ### JSON-RPC 2.0
325
+
326
+ `jsonRPCServiceEntrypoint()` serves the actions over JSON-RPC 2.0: it suits the clients written in other languages,
327
+ the tools that speak the protocol, and the parent processes over the standard input and output.
328
+
329
+ | Option | Default | Meaning |
330
+ |---|---|---|
331
+ | `transport` | `'http'` | `'http'` (a `POST` per message), `'websocket'` (needs `ws`) or `'stdio'` |
332
+ | `port`, `host`, `path` | `3000`, all the interfaces, `'/'` | The server of HTTP or WebSocket |
333
+ | `limit` | `10485760` (10 MiB) | The largest message, in bytes |
334
+ | `methodField` | `'method'` | The key of the pattern that receives the method |
335
+ | `positionalParams` | | The key of the input that receives the params given as an array; without it, they are refused |
336
+ | `toPattern` | | `(method, params) => message`: builds the message itself, replacing the two options above |
337
+ | `framing` | `'newline'` | On stdio: one message per line, or `'content-length'` (headers, as the Language Server Protocol) |
338
+ | `input`, `output` | `process.stdin`, `process.stdout` | The streams of stdio |
339
+ | `end` | `'application'` | When the input of stdio ends: `'application'` exits the application once the requests in progress are answered, `'none'` does nothing, or a function |
340
+ | `heartbeat` | `30000` | The interval of the pings of WebSocket, in milliseconds; `0` sends none |
341
+ | `listening` | | `(server) => …`, called once the server of HTTP or WebSocket listens |
342
+
343
+ The method and the params of a request make the message: `{"method": "order.get", "params": {"id": 1}}` is the message
344
+ `{method: 'order.get', id: 1}`, so the action declares the method in its pattern:
345
+
346
+ ```typescript
347
+ import {Controller} from 'lakutata/com/entrypoint'
348
+ import {ServiceAction} from 'lakutata/decorator/ctrl'
349
+
350
+ class OrderRPCController extends Controller {
351
+ //{"jsonrpc": "2.0", "id": 1, "method": "order.get", "params": {"id": 1}}
352
+ @ServiceAction({method: 'order.get'})
353
+ public async get(input: { id: number }): Promise<{ id: number }> {
354
+ return {id: input.id}
355
+ }
356
+ }
357
+ ```
358
+
359
+ - The result is the `result` of the response, `null` when the action returns nothing.
360
+ - A request without `id` is a notification: the action runs, and nothing is answered (204 over HTTP). A batch, an array
361
+ of requests, is answered with an array of responses.
362
+ - An error is an `error` with a code:
363
+
364
+ | Code | When |
365
+ |---|---|
366
+ | `-32700` | The message is not JSON |
367
+ | `-32600` | The message is not a JSON-RPC 2.0 request, or the batch is empty |
368
+ | `-32601` | No action matches the request |
369
+ | `-32602` | The input fails the validation of the DTO, or the params are an array without `positionalParams` |
370
+ | `-32000` | Any other error of the action, the whole error being its `data` |
371
+ | `-32001` | The application is stopping |
372
+
373
+ - Over HTTP, the requests are `POST`s to `path`, answered with 200; another method or path is answered with 404. The
374
+ CORS headers allow any origin and any request header.
375
+ - Over WebSocket, each text frame is a message. The events pushed to the client are sent as notifications:
376
+ `{"jsonrpc": "2.0", "method": "<event>", "params": [<arguments>]}`.
377
+
378
+ **Over stdio**, the application is a child process: its parent writes the requests on its standard input and reads the
379
+ responses and the events on its standard output. The standard output belongs to the protocol: write the logs of the
380
+ application to the standard error, and never call `console.log()`.
381
+
382
+ ```typescript
383
+ import {Application} from 'lakutata'
384
+ import {buildEntrypoints, Controller} from 'lakutata/com/entrypoint'
385
+ import {ServiceAction} from 'lakutata/decorator/ctrl'
386
+ import {Logger} from 'lakutata/com/logger'
387
+ import {jsonRPCServiceEntrypoint} from 'lakutata/adapter/jsonrpc'
388
+
389
+ class ToolController extends Controller {
390
+ @ServiceAction({method: 'sum'})
391
+ public async sum(input: { values: number[] }): Promise<number> {
392
+ return input.values.reduce((total: number, value: number): number => total + value, 0)
393
+ }
394
+ }
395
+
396
+ Application.run(() => ({
397
+ id: 'tool.app',
398
+ name: 'Tool',
399
+ components: {
400
+ //The standard output belongs to the protocol
401
+ log: {class: Logger, destinations: [process.stderr]},
402
+ entrypoint: buildEntrypoints({
403
+ controllers: [ToolController],
404
+ service: jsonRPCServiceEntrypoint({transport: 'stdio'})
405
+ })
406
+ },
407
+ bootstrap: ['entrypoint']
408
+ }))
409
+ ```
410
+
411
+ ```text
412
+ $ echo '{"jsonrpc": "2.0", "id": 1, "method": "sum", "params": {"values": [1, 2, 3]}}' | node build/App.js
413
+ {"jsonrpc":"2.0","id":1,"result":6}
414
+ ```
415
+
416
+ The application exits once its input ends and its requests are answered (`end`).
417
+
418
+ ### Electron
419
+
420
+ `electronIPCServiceEntrypoint()` serves the actions to the windows of an Electron application, in its main process.
421
+
422
+ | Option | Default | Meaning |
423
+ |---|---|---|
424
+ | `channel` | `'lakutata:service'` | The IPC channel of the requests; the events use `<channel>:event`, the subscriptions `<channel>:subscribe` |
425
+ | `ipcMain` | the `ipcMain` of Electron | The `ipcMain` to serve, or a stand-in in the tests |
426
+
427
+ The pages call the main process through the preload script of their window:
428
+
429
+ ```typescript
430
+ import {contextBridge, ipcRenderer, type IpcRendererEvent} from 'electron'
431
+
432
+ contextBridge.exposeInMainWorld('service', {
433
+ //Resolves to the envelope, never rejects
434
+ invoke: (message: Record<string, unknown>, clientId: string): Promise<unknown> => ipcRenderer.invoke('lakutata:service', message, clientId),
435
+ //The events pushed to the page: their name, and the array of their arguments
436
+ subscribe: (clientId: string, listener: (event: string, args: unknown[]) => void): void => {
437
+ ipcRenderer.on('lakutata:service:event', (_event: IpcRendererEvent, name: string, args: unknown[]): void => listener(name, args))
438
+ ipcRenderer.send('lakutata:service:subscribe', clientId)
439
+ }
440
+ })
441
+ ```
442
+
443
+ - Each page gives its own client id (`crypto.randomUUID()`) when it subscribes and when it invokes; a page reloaded in
444
+ its window replaces its previous id. A page without an id receives no event.
445
+ - `ipcEventOfContext(this.context)`, from `lakutata/adapter/electron`, gives the IPC event of a request, to reach the
446
+ window that sent it.
447
+ - The Electron templates of `lakutata create` contain a preload script of this shape, and a `window.ipc` for the pages,
448
+ whose `invoke()` returns the payload or throws the error.
449
+
450
+ ## 5. Clients and events
451
+
452
+ The transports that keep their clients connected can push events to them. A client is identified by the id it gives:
453
+
454
+ | Transport | Client id |
455
+ |---|---|
456
+ | HTTP service | The `id` header or `id` query parameter of the request; for the event stream, a random id when it gives none |
457
+ | Socket.IO | The `auth.id` of the connection, else its `id` header, else its `id` query parameter, else the id of the socket |
458
+ | JSON-RPC over HTTP | The `id` header or `id` query parameter of the request; it receives no event |
459
+ | JSON-RPC over WebSocket | The `id` header or `id` query parameter of the connection, else a random id |
460
+ | JSON-RPC over stdio | `'stdio'` |
461
+ | Electron | The id the page gives when it subscribes or invokes |
462
+
463
+ An action reads the id of its client in `this.context.clientId`. The `entrypoint` component pushes the events:
464
+
465
+ - `notify(clientId, event, ...args)` sends an event to a client, on every transport where it is connected, and returns
466
+ whether it reached it;
467
+ - `broadcast(event, ...args)` sends an event to every connected client, and returns their number.
468
+
469
+ ```typescript
470
+ import {Controller, Entrypoint, ServiceContext} from 'lakutata/com/entrypoint'
471
+ import {ServiceAction} from 'lakutata/decorator/ctrl'
472
+ import {Inject} from 'lakutata/decorator/di'
473
+
474
+ class TaskController extends Controller {
475
+ @Inject('entrypoint')
476
+ protected readonly entrypoint: Entrypoint
477
+
478
+ @ServiceAction({cmd: 'start'})
479
+ public async start(): Promise<void> {
480
+ const clientId: string | undefined = (this.context as ServiceContext).clientId
481
+ //The client is told when its task is done, every client that a task started
482
+ if (clientId) setTimeout((): void => void this.entrypoint.notify(clientId, 'done', {task: 1}), 1000)
483
+ this.entrypoint.broadcast('started', {task: 1})
484
+ }
485
+ }
486
+ ```
487
+
488
+ A client receives `notify(clientId, 'progress', 50, 'half')` as:
489
+
490
+ | Transport | Received |
491
+ |---|---|
492
+ | HTTP service | `event: progress` and `data: [50,"half"]` on its event stream |
493
+ | Socket.IO | `socket.on('progress', (percent, label) => …)` |
494
+ | JSON-RPC (WebSocket, stdio) | `{"jsonrpc": "2.0", "method": "progress", "params": [50, "half"]}` |
495
+ | Electron | `('progress', [50, 'half'])`, through the `subscribe` of the preload script above |
496
+
497
+ ## 6. Stopping and leaving
498
+
499
+ - When the application stops, the new requests fail with `EntrypointStoppedException` (`E_ENTRYPOINT_STOPPED`; the
500
+ code `-32001` over JSON-RPC), and the requests in progress get the `shutdownTimeout` of the entrypoint (see
501
+ [Entrypoint](Entrypoint.md), section 4).
502
+ - When a client leaves before its response (a socket disconnected, a window closed, an HTTP connection closed), the
503
+ `this.signal` of its request is aborted.
504
+ - The transports set no time limit to the calls: a client sets its own, such as `socket.timeout(5000)` above.
package/doc/en/Time.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Lakutata Time
2
2
 
3
- `Time` is a `Date` in a time zone, with a moment-like API. It is exported by `lakutata` (and `@lakutata/utils`) and
4
- has no dependency: the time zones come from the runtime (Intl), so their rules follow the Node.js version.
3
+ `Time` is a `Date` in a time zone, with a moment-like API. It is exported by `lakutata` (and `@lakutata/utils`) and has
4
+ no dependencies: time zones come from the runtime (`Intl`), so their rules follow the version of Node.js.
5
5
 
6
6
  ```typescript
7
7
  import {Time} from 'lakutata'
@@ -17,34 +17,33 @@ time.isBetween('2024-01-01', '2024-12-31', 'day', '[]') // true
17
17
  ## Values
18
18
 
19
19
  - A `Time` is a `Date`: it can be used wherever a `Date` is expected (`DTO.Date()`, ORM columns, `JSON.stringify`).
20
- - The methods returning a `Time` return a new instance: `add`, `subtract`, `startOf`, `endOf`, `set`, the setters
21
- (`hours(5)`, `month(0)`...), `timezone(zone)`, `clone()`. The instance they are called on does not change. The methods
22
- inherited from `Date` (`getHours()`, `setHours()`...) read and modify the time in the time zone of the process, not
23
- in the one of the instance, and the setters modify the instance: use the methods of `Time`.
20
+ - The methods that return a `Time` return a new instance: `add`, `subtract`, `startOf`, `endOf`, `set`, the setters
21
+ (`hours(5)`, `month(0)`…), `timezone(zone)`, `clone()`. The instance they are called on does not change. The methods
22
+ inherited from `Date` (`getHours()`, `setHours()`…) read and change the time in the time zone of the process, not in
23
+ that of the instance, and their setters change the instance itself: use the methods of `Time` instead.
24
24
  - The constructor takes a `Time` (its time zone too), a `Date`, a timestamp in milliseconds, a date string (a numeric
25
25
  string is a timestamp; an invalid string throws a `RangeError`), or the wall clock fields `[year, month, date, hours,
26
- minutes, seconds, milliseconds]` of the default time zone. Without argument (or `null`), it is now. A string without
27
- offset is read in the default time zone.
28
- - An invalid time (a field of the array out of its range) does not throw: its `format()` gives `'Invalid date'`.
26
+ minutes, seconds, milliseconds]` of the default time zone. Without an argument (or with `null`), it is the current
27
+ time. A string without an offset is read in the default time zone.
28
+ - An invalid time (an array field out of range) does not throw: its `format()` returns `'Invalid date'`.
29
29
 
30
30
  ## Time zones
31
31
 
32
- - `timezone()` gives the time zone, `timezone(zone)` a new time in another zone (the same instant); `Time.timezones()`
33
- lists the supported zones by their current IANA names, with `UTC` and the fixed offsets `Etc/GMT-14`…`Etc/GMT+12`.
34
- The former names and the aliases (`Asia/Calcutta`, `US/Eastern`) are accepted but not listed (the list of 2.x, the one
35
- of moment-timezone, had them). An unknown zone throws a `RangeError`.
36
- - `utcOffset()` is the offset of the zone in minutes (480 for UTC+8); `getTimezoneOffset()` is the one of `Date`
37
- (-480 for UTC+8).
38
- - The getters, the setters, the calendar operations (`add` of days, months...) and `format` use the wall clock of the
39
- zone. Adding days keeps the wall clock across daylight saving transitions; a wall clock time which does not exist
40
- moves forward.
32
+ - `timezone()` returns the time zone, and `timezone(zone)` a new time in another zone (the same instant).
33
+ `Time.timezones()` lists the supported zones by their current IANA names, with `UTC` and the fixed offsets
34
+ `Etc/GMT-14`…`Etc/GMT+12`. Former names and aliases (`Asia/Calcutta`, `US/Eastern`) are accepted but not listed (the
35
+ list of 2.x, taken from moment-timezone, included them). An unknown zone throws a `RangeError`.
36
+ - `utcOffset()` is the offset of the zone in minutes (480 for UTC+8); `getTimezoneOffset()` is that of `Date` (-480 for
37
+ UTC+8).
38
+ - Getters, setters, calendar arithmetic (adding days, months…) and `format` use the wall clock of the zone. Adding days
39
+ keeps the wall-clock time across daylight saving changes; a wall-clock time that does not exist moves forward.
41
40
 
42
41
  ## Arithmetic
43
42
 
44
43
  - `add()` and `subtract()` move the wall clock for the years, quarters, months, weeks and days, and the instant for the
45
44
  hours, minutes, seconds and milliseconds: across a daylight saving change, `add(1, 'day')` keeps the hour,
46
- `add(24, 'hours')` does not. A month without the day takes its last day (January 31 plus one month is February 29
47
- in 2024).
45
+ `add(24, 'hours')` does not. When the target month lacks the day, its last day is used (January 31 plus one month is
46
+ February 29 in 2024).
48
47
  - `diff(other, unit, precise?)` is `this - other` in the unit, truncated toward zero unless `precise` is `true`.
49
48
  - `isBefore()`, `isAfter()`, `isSame()`, `isBetween()` compare at a granularity (`'day'`…); `Time.max()` and
50
49
  `Time.min()` give the latest and the earliest of several times.