@lakutata/entrypoint 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 (129) hide show
  1. package/README.md +39 -26
  2. package/README.zh-CN.md +182 -0
  3. package/dist/cjs/Entrypoint.d.ts +13 -7
  4. package/dist/cjs/Entrypoint.js +12 -6
  5. package/dist/cjs/adapter/commander/CommanderCLIEntrypoint.d.ts +3 -2
  6. package/dist/cjs/adapter/commander/CommanderCLIEntrypoint.js +3 -2
  7. package/dist/cjs/adapter/electron/ElectronIPCServiceEntrypoint.d.ts +2 -1
  8. package/dist/cjs/adapter/electron/ElectronIPCServiceEntrypoint.js +2 -1
  9. package/dist/cjs/adapter/express/ExpressHTTPEntrypoint.d.ts +3 -2
  10. package/dist/cjs/adapter/express/ExpressHTTPEntrypoint.js +8 -2
  11. package/dist/cjs/adapter/express/Responders.d.ts +2 -1
  12. package/dist/cjs/adapter/express/Responders.js +4 -2
  13. package/dist/cjs/adapter/express/StaticUI.d.ts +2 -1
  14. package/dist/cjs/adapter/express/StaticUI.js +2 -1
  15. package/dist/cjs/adapter/fastify/FastifyHTTPEntrypoint.d.ts +2 -1
  16. package/dist/cjs/adapter/fastify/FastifyHTTPEntrypoint.js +4 -1
  17. package/dist/cjs/adapter/fastify/Responders.d.ts +2 -1
  18. package/dist/cjs/adapter/fastify/Responders.js +4 -2
  19. package/dist/cjs/adapter/http/HTTPServiceEntrypoint.d.ts +4 -3
  20. package/dist/cjs/adapter/http/HTTPServiceEntrypoint.js +10 -22
  21. package/dist/cjs/adapter/http/HTTPServiceProxy.d.ts +190 -0
  22. package/dist/cjs/adapter/http/HTTPServiceProxy.js +371 -0
  23. package/dist/cjs/adapter/jsonrpc/JSONRPCServiceEntrypoint.d.ts +11 -7
  24. package/dist/cjs/adapter/jsonrpc/JSONRPCServiceEntrypoint.js +15 -26
  25. package/dist/cjs/adapter/schedule/ScheduleEntrypoint.d.ts +2 -1
  26. package/dist/cjs/adapter/schedule/ScheduleEntrypoint.js +12 -5
  27. package/dist/cjs/adapter/socket.io/SocketIOServiceEntrypoint.d.ts +2 -1
  28. package/dist/cjs/adapter/socket.io/SocketIOServiceEntrypoint.js +2 -1
  29. package/dist/cjs/channel/BuiltinChannels.js +4 -2
  30. package/dist/cjs/channel/EntrypointAdapter.d.ts +2 -2
  31. package/dist/cjs/decorators/HTTPAction.d.ts +5 -5
  32. package/dist/cjs/decorators/Schedule.d.ts +4 -2
  33. package/dist/cjs/decorators/Schedule.js +3 -2
  34. package/dist/cjs/decorators/ServiceAction.d.ts +12 -8
  35. package/dist/cjs/decorators/http/DELETE.d.ts +5 -5
  36. package/dist/cjs/decorators/http/GET.d.ts +7 -7
  37. package/dist/cjs/decorators/http/HEAD.d.ts +9 -9
  38. package/dist/cjs/decorators/http/OPTIONS.d.ts +8 -7
  39. package/dist/cjs/decorators/http/PATCH.d.ts +5 -5
  40. package/dist/cjs/decorators/http/POST.d.ts +5 -5
  41. package/dist/cjs/decorators/http/PUT.d.ts +7 -7
  42. package/dist/cjs/exceptions/AccessDeniedException.d.ts +7 -1
  43. package/dist/cjs/exceptions/AccessDeniedException.js +8 -3
  44. package/dist/cjs/exceptions/EntrypointStoppedException.d.ts +2 -1
  45. package/dist/cjs/exceptions/EntrypointStoppedException.js +2 -1
  46. package/dist/cjs/exceptions/InvalidActionGroupException.d.ts +4 -0
  47. package/dist/cjs/exceptions/InvalidActionGroupException.js +4 -0
  48. package/dist/cjs/exceptions/ServiceUnavailableException.d.ts +15 -0
  49. package/dist/cjs/exceptions/ServiceUnavailableException.js +18 -0
  50. package/dist/cjs/exports/Entrypoint.d.ts +3 -0
  51. package/dist/cjs/exports/Entrypoint.js +6 -1
  52. package/dist/cjs/lib/AccessControl.d.ts +2 -1
  53. package/dist/cjs/lib/AccessControl.js +2 -1
  54. package/dist/cjs/lib/AccessControlRule.d.ts +2 -1
  55. package/dist/cjs/lib/AccessControlRule.js +2 -1
  56. package/dist/cjs/lib/ExplicitStatus.d.ts +11 -0
  57. package/dist/cjs/lib/ExplicitStatus.js +22 -0
  58. package/dist/cjs/lib/Middleware.d.ts +2 -1
  59. package/dist/cjs/lib/Middleware.js +2 -1
  60. package/dist/cjs/lib/Preflight.d.ts +8 -0
  61. package/dist/cjs/lib/Preflight.js +13 -0
  62. package/dist/cjs/lib/ReadBody.d.ts +9 -0
  63. package/dist/cjs/lib/ReadBody.js +25 -0
  64. package/dist/cjs/lib/pattern/FlattenPattern.d.ts +14 -0
  65. package/dist/cjs/lib/pattern/FlattenPattern.js +59 -0
  66. package/dist/esm/Entrypoint.js +12 -6
  67. package/dist/esm/adapter/commander/CommanderCLIEntrypoint.js +3 -2
  68. package/dist/esm/adapter/electron/ElectronIPCServiceEntrypoint.js +2 -1
  69. package/dist/esm/adapter/express/ExpressHTTPEntrypoint.js +8 -2
  70. package/dist/esm/adapter/express/Responders.js +4 -2
  71. package/dist/esm/adapter/express/StaticUI.js +2 -1
  72. package/dist/esm/adapter/fastify/FastifyHTTPEntrypoint.js +4 -1
  73. package/dist/esm/adapter/fastify/Responders.js +4 -2
  74. package/dist/esm/adapter/http/HTTPServiceEntrypoint.js +9 -21
  75. package/dist/esm/adapter/http/HTTPServiceProxy.js +366 -0
  76. package/dist/esm/adapter/jsonrpc/JSONRPCServiceEntrypoint.js +14 -25
  77. package/dist/esm/adapter/schedule/ScheduleEntrypoint.js +12 -5
  78. package/dist/esm/adapter/socket.io/SocketIOServiceEntrypoint.js +2 -1
  79. package/dist/esm/channel/BuiltinChannels.js +4 -2
  80. package/dist/esm/decorators/Schedule.js +3 -2
  81. package/dist/esm/exceptions/AccessDeniedException.js +8 -3
  82. package/dist/esm/exceptions/EntrypointStoppedException.js +2 -1
  83. package/dist/esm/exceptions/InvalidActionGroupException.js +4 -0
  84. package/dist/esm/exceptions/ServiceUnavailableException.js +14 -0
  85. package/dist/esm/exports/Entrypoint.js +3 -0
  86. package/dist/esm/lib/AccessControl.js +2 -1
  87. package/dist/esm/lib/AccessControlRule.js +2 -1
  88. package/dist/esm/lib/ExplicitStatus.js +18 -0
  89. package/dist/esm/lib/Middleware.js +2 -1
  90. package/dist/esm/lib/Preflight.js +10 -0
  91. package/dist/esm/lib/ReadBody.js +22 -0
  92. package/dist/esm/lib/pattern/FlattenPattern.js +55 -0
  93. package/dist/types/Entrypoint.d.ts +13 -7
  94. package/dist/types/adapter/commander/CommanderCLIEntrypoint.d.ts +3 -2
  95. package/dist/types/adapter/electron/ElectronIPCServiceEntrypoint.d.ts +2 -1
  96. package/dist/types/adapter/express/ExpressHTTPEntrypoint.d.ts +3 -2
  97. package/dist/types/adapter/express/Responders.d.ts +2 -1
  98. package/dist/types/adapter/express/StaticUI.d.ts +2 -1
  99. package/dist/types/adapter/fastify/FastifyHTTPEntrypoint.d.ts +2 -1
  100. package/dist/types/adapter/fastify/Responders.d.ts +2 -1
  101. package/dist/types/adapter/http/HTTPServiceEntrypoint.d.ts +4 -3
  102. package/dist/types/adapter/http/HTTPServiceProxy.d.ts +190 -0
  103. package/dist/types/adapter/jsonrpc/JSONRPCServiceEntrypoint.d.ts +11 -7
  104. package/dist/types/adapter/schedule/ScheduleEntrypoint.d.ts +2 -1
  105. package/dist/types/adapter/socket.io/SocketIOServiceEntrypoint.d.ts +2 -1
  106. package/dist/types/channel/EntrypointAdapter.d.ts +2 -2
  107. package/dist/types/decorators/HTTPAction.d.ts +5 -5
  108. package/dist/types/decorators/Schedule.d.ts +4 -2
  109. package/dist/types/decorators/ServiceAction.d.ts +12 -8
  110. package/dist/types/decorators/http/DELETE.d.ts +5 -5
  111. package/dist/types/decorators/http/GET.d.ts +7 -7
  112. package/dist/types/decorators/http/HEAD.d.ts +9 -9
  113. package/dist/types/decorators/http/OPTIONS.d.ts +8 -7
  114. package/dist/types/decorators/http/PATCH.d.ts +5 -5
  115. package/dist/types/decorators/http/POST.d.ts +5 -5
  116. package/dist/types/decorators/http/PUT.d.ts +7 -7
  117. package/dist/types/exceptions/AccessDeniedException.d.ts +7 -1
  118. package/dist/types/exceptions/EntrypointStoppedException.d.ts +2 -1
  119. package/dist/types/exceptions/InvalidActionGroupException.d.ts +4 -0
  120. package/dist/types/exceptions/ServiceUnavailableException.d.ts +15 -0
  121. package/dist/types/exports/Entrypoint.d.ts +3 -0
  122. package/dist/types/lib/AccessControl.d.ts +2 -1
  123. package/dist/types/lib/AccessControlRule.d.ts +2 -1
  124. package/dist/types/lib/ExplicitStatus.d.ts +11 -0
  125. package/dist/types/lib/Middleware.d.ts +2 -1
  126. package/dist/types/lib/Preflight.d.ts +8 -0
  127. package/dist/types/lib/ReadBody.d.ts +9 -0
  128. package/dist/types/lib/pattern/FlattenPattern.d.ts +14 -0
  129. package/package.json +35 -4
@@ -0,0 +1,366 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ import { randomUUID } from 'node:crypto';
11
+ import { Component, DTO } from '@lakutata/core';
12
+ import { Configurable } from '@lakutata/core/decorator/di';
13
+ import { ServiceResponseCodec } from '../../lib/ServiceResponseCodec.js';
14
+ import { ServiceUnavailableException } from '../../exceptions/ServiceUnavailableException.js';
15
+ /**
16
+ * Build the component options of an {@link HTTPServiceProxy}, to register it in the `components` of an application.
17
+ * @param options The service's address, and the headers, client id and reconnection delay.
18
+ * @returns The component options.
19
+ * @example
20
+ * ```typescript
21
+ * import {Application} from 'lakutata'
22
+ * import {buildHTTPServiceProxy, type HTTPServiceProxy} from 'lakutata/com/entrypoint'
23
+ *
24
+ * Application
25
+ * .run(() => ({
26
+ * id: 'shop.app',
27
+ * name: 'Shop',
28
+ * components: {
29
+ * inventory: buildHTTPServiceProxy({url: 'http://inventory.internal:3000/'}),
30
+ * //With a token, and a stable client id to receive the events of the service
31
+ * orders: buildHTTPServiceProxy({
32
+ * url: 'https://orders.internal/rpc',
33
+ * headers: {Authorization: `Bearer ${process.env.ORDERS_TOKEN}`},
34
+ * clientId: 'shop-1'
35
+ * })
36
+ * }
37
+ * }))
38
+ * .onLaunched(async (app: Application): Promise<void> => {
39
+ * const inventory: HTTPServiceProxy = await app.getObject<HTTPServiceProxy>('inventory')
40
+ * console.log(await inventory.invoke({cmd: 'stock.get', product: 'lamp'}))
41
+ * })
42
+ * ```
43
+ */
44
+ export const buildHTTPServiceProxy = (options) => {
45
+ return {
46
+ class: HTTPServiceProxy,
47
+ url: options.url,
48
+ headers: options.headers,
49
+ clientId: options.clientId,
50
+ reconnectDelay: options.reconnectDelay
51
+ };
52
+ };
53
+ /**
54
+ * Whether a body is a service response
55
+ * @param body
56
+ */
57
+ function isServiceResponse(body) {
58
+ return !!body && typeof body === 'object' && typeof body.success === 'boolean';
59
+ }
60
+ /**
61
+ * The reason a request failed: the network error under the "fetch failed" of fetch(), each of them when several
62
+ * addresses were tried (localhost: ::1, then 127.0.0.1)
63
+ * @param error
64
+ */
65
+ function reasonOf(error) {
66
+ const cause = error?.cause ?? error;
67
+ if (cause instanceof AggregateError && cause.errors.length)
68
+ return cause.errors.map((each) => each?.message ?? String(each)).join(', ');
69
+ return cause?.message || error?.message || String(error);
70
+ }
71
+ /**
72
+ * The events of a stream of Server-Sent Events: their names and their data, the comments skipped
73
+ * @param stream
74
+ */
75
+ async function* serverSentEvents(stream) {
76
+ const reader = stream.getReader();
77
+ const decoder = new TextDecoder();
78
+ let buffer = '';
79
+ let event = 'message';
80
+ let data = [];
81
+ try {
82
+ for (;;) {
83
+ const { value, done } = await reader.read();
84
+ if (done)
85
+ return;
86
+ buffer += decoder.decode(value, { stream: true });
87
+ //The complete lines; the last one may be cut by the chunk
88
+ const lines = buffer.split('\n');
89
+ buffer = lines.pop();
90
+ for (const read of lines) {
91
+ const line = read.endsWith('\r') ? read.slice(0, -1) : read;
92
+ //A blank line ends the event
93
+ if (!line) {
94
+ if (data.length)
95
+ yield { event: event, data: data.join('\n') };
96
+ event = 'message';
97
+ data = [];
98
+ continue;
99
+ }
100
+ if (line.startsWith(':'))
101
+ continue;
102
+ const colon = line.indexOf(':');
103
+ const field = colon < 0 ? line : line.slice(0, colon);
104
+ const value = colon < 0 ? '' : line.slice(colon + 1).replace(/^ /, '');
105
+ if (field === 'event')
106
+ event = value;
107
+ else if (field === 'data')
108
+ data.push(value);
109
+ }
110
+ }
111
+ }
112
+ finally {
113
+ reader.releaseLock();
114
+ }
115
+ }
116
+ /**
117
+ * The client of a lakutata service served over HTTP by `httpServiceEntrypoint`: it invokes the service's actions by
118
+ * their patterns and listens to the events the service pushes to it. It is a component (a singleton), registered with
119
+ * {@link buildHTTPServiceProxy}; it needs no package (it uses the `fetch()` of Node.js), and its event stream is opened
120
+ * when the first listener is added.
121
+ *
122
+ * The failures of the remote actions are thrown as `ServiceInvokeException`, with the remote error's message and
123
+ * properties (`errno`, `statusCode`…) and the address as its `service`; a service that gives no response (unreachable,
124
+ * too slow, or an answer that is not a service response) as a `ServiceUnavailableException`.
125
+ * @example
126
+ * ```typescript
127
+ * import {Component} from 'lakutata'
128
+ * import {type HTTPServiceProxy, ServiceInvokeException, ServiceUnavailableException} from 'lakutata/com/entrypoint'
129
+ * import {Inject} from 'lakutata/decorator/di'
130
+ *
131
+ * class StockWatcher extends Component {
132
+ * //Registered as inventory: buildHTTPServiceProxy({url: 'http://inventory.internal:3000/'})
133
+ * @Inject('inventory')
134
+ * protected readonly inventory: HTTPServiceProxy
135
+ *
136
+ * protected async init(): Promise<void> {
137
+ * //The events the inventory pushes with Entrypoint.notify(clientId, 'lowStock', product, quantity)
138
+ * this.inventory.on('lowStock', (product: string, quantity: number): void => {
139
+ * console.log(`${product}: ${quantity} left`)
140
+ * })
141
+ * }
142
+ *
143
+ * public async quantity(product: string): Promise<number | undefined> {
144
+ * try {
145
+ * //5 seconds at most; the inventory aborts the action's signal when the call gives up
146
+ * return await this.inventory.invoke({cmd: 'stock.get', product: product}, 5000)
147
+ * } catch (error) {
148
+ * //The action failed: its error
149
+ * if (error instanceof ServiceInvokeException && error.errno === 'E_PRODUCT_NOT_FOUND') return undefined
150
+ * //The inventory gave no response
151
+ * if (error instanceof ServiceUnavailableException) return undefined
152
+ * throw error
153
+ * }
154
+ * }
155
+ * }
156
+ * ```
157
+ */
158
+ export class HTTPServiceProxy extends Component {
159
+ #id;
160
+ #eventsURL;
161
+ #listeners = new Map();
162
+ #events;
163
+ #destroyed = false;
164
+ /**
165
+ * Create the client id and the address of the event stream (`<url>/events?id=<id>`); nothing connects before the
166
+ * first call.
167
+ * @protected
168
+ */
169
+ async init() {
170
+ this.#id = this.clientId ?? randomUUID();
171
+ const events = new URL(this.url);
172
+ events.pathname = `${events.pathname.replace(/\/+$/, '')}/events`;
173
+ events.searchParams.set('id', this.#id);
174
+ this.#eventsURL = events.href;
175
+ }
176
+ /**
177
+ * Close the event stream and remove the listeners (the calls in progress complete).
178
+ * @protected
179
+ */
180
+ async destroy() {
181
+ this.#destroyed = true;
182
+ this.#events?.abort();
183
+ this.#listeners.clear();
184
+ }
185
+ /**
186
+ * The client's id: the configured `clientId`, or a random UUID. It is sent as the `id` header of the calls, and is
187
+ * the `ServiceContext.clientId` of the service's actions.
188
+ */
189
+ get id() {
190
+ return this.#id;
191
+ }
192
+ /**
193
+ * Invoke an action of the service, matched by its pattern.
194
+ * @param input The action's pattern and its input in one object (`{cmd: 'order.get', id: 1}`), sent as JSON: the
195
+ * values JSON cannot hold (functions, `undefined`, class instances) are lost or turned into plain objects.
196
+ * @param timeout The longest wait for the response, in milliseconds; 600000 (10 minutes) by default. The request is
197
+ * abandoned after it, and the service aborts the action's signal.
198
+ * @returns The action's result, decoded from JSON: `undefined` when the action returned nothing.
199
+ * @throws {ServiceInvokeException} When the action fails (no action matches the pattern, an invalid input, an error
200
+ * thrown, the service stopping), or the service refuses the request (another path, a body over its limit): the
201
+ * remote error's message and properties (`errno`…), the address as its `service`.
202
+ * @throws {ServiceUnavailableException} When the service gives no response: unreachable, no response within the
203
+ * timeout, or an answer that is not a service response. Its `cause` is the error of the request.
204
+ */
205
+ async invoke(input, timeout = 10 * 60 * 1000) {
206
+ let status;
207
+ let text;
208
+ try {
209
+ const response = await fetch(this.url, {
210
+ method: 'POST',
211
+ headers: { 'Content-Type': 'application/json', ...this.headers, 'id': this.#id },
212
+ body: JSON.stringify(input),
213
+ signal: AbortSignal.timeout(timeout)
214
+ });
215
+ status = response.status;
216
+ text = await response.text();
217
+ }
218
+ catch (error) {
219
+ const exception = error?.name === 'TimeoutError'
220
+ ? new ServiceUnavailableException('The service {service} gave no response within {timeout} ms', { service: this.url, timeout: timeout })
221
+ : new ServiceUnavailableException('The service {service} cannot be reached: {reason}', { service: this.url, reason: reasonOf(error) });
222
+ exception.service = this.url;
223
+ exception.cause = error;
224
+ throw exception;
225
+ }
226
+ let body;
227
+ try {
228
+ body = JSON.parse(text);
229
+ }
230
+ catch {
231
+ //Not JSON: below
232
+ }
233
+ if (!isServiceResponse(body)) {
234
+ const exception = new ServiceUnavailableException('The service {service} answered {status} without a service response', { service: this.url, status: status });
235
+ exception.service = this.url;
236
+ throw exception;
237
+ }
238
+ return ServiceResponseCodec.decode(body, this.url);
239
+ }
240
+ /**
241
+ * Listen to an event the service pushes to this client (`Entrypoint.notify()`, `broadcast()`); the first listener
242
+ * opens the event stream. The listeners' errors are ignored. The `client` event, received when the stream opens,
243
+ * carries the client's id.
244
+ * @param eventName The event's name.
245
+ * @param listener Called with the event's arguments, decoded from JSON.
246
+ * @returns The proxy, to chain the calls.
247
+ */
248
+ on(eventName, listener) {
249
+ return this.add(eventName, listener, false);
250
+ }
251
+ /**
252
+ * Listen to an event the service pushes to this client, once: the listener is removed before it is called.
253
+ * @param eventName The event's name.
254
+ * @param listener Called with the event's arguments, decoded from JSON.
255
+ * @returns The proxy, to chain the calls.
256
+ */
257
+ once(eventName, listener) {
258
+ return this.add(eventName, listener, true);
259
+ }
260
+ /**
261
+ * Stop listening to an event; removing the last listener of all the events closes the event stream.
262
+ * @param eventName The event's name.
263
+ * @param listener The listener to remove; without it, all the event's listeners are removed.
264
+ * @returns The proxy, to chain the calls.
265
+ */
266
+ off(eventName, listener) {
267
+ const listeners = this.#listeners.get(eventName);
268
+ if (listeners && listener)
269
+ listeners.forEach((entry) => entry.listener === listener && listeners.delete(entry));
270
+ if (!listener || !listeners?.size)
271
+ this.#listeners.delete(eventName);
272
+ if (!this.#listeners.size) {
273
+ this.#events?.abort();
274
+ this.#events = undefined;
275
+ }
276
+ return this;
277
+ }
278
+ /**
279
+ * Add a listener, and open the event stream when it is not open.
280
+ * @param eventName The event's name.
281
+ * @param listener The listener.
282
+ * @param once Whether the listener is removed after its first call.
283
+ * @returns The proxy.
284
+ * @protected
285
+ */
286
+ add(eventName, listener, once) {
287
+ if (!this.#listeners.has(eventName))
288
+ this.#listeners.set(eventName, new Set());
289
+ this.#listeners.get(eventName).add({ listener: listener, once: once });
290
+ if (!this.#events)
291
+ void this.stream();
292
+ return this;
293
+ }
294
+ /**
295
+ * Read the event stream and call the listeners; the stream is opened again after `reconnectDelay` milliseconds
296
+ * when it ends or fails, while there are listeners. It stops once no listener is left (removed by
297
+ * {@link HTTPServiceProxy.off}, or the last `once()` listener called) or the proxy is destroyed.
298
+ * @returns A promise resolved once the reading stops.
299
+ * @protected
300
+ */
301
+ async stream() {
302
+ const abort = new AbortController();
303
+ this.#events = abort;
304
+ while (!abort.signal.aborted && !this.#destroyed && this.#listeners.size) {
305
+ try {
306
+ const response = await fetch(this.#eventsURL, { headers: { ...this.headers, 'Accept': 'text/event-stream' }, signal: abort.signal });
307
+ if (response.ok && response.body) {
308
+ for await (const { event, data } of serverSentEvents(response.body)) {
309
+ const listeners = this.#listeners.get(event);
310
+ if (!listeners)
311
+ continue;
312
+ let args;
313
+ try {
314
+ args = JSON.parse(data);
315
+ }
316
+ catch {
317
+ continue;
318
+ }
319
+ for (const entry of [...listeners]) {
320
+ if (entry.once)
321
+ listeners.delete(entry);
322
+ try {
323
+ entry.listener(...Array.isArray(args) ? args : [args]);
324
+ }
325
+ catch {
326
+ //The errors of the listeners are theirs
327
+ }
328
+ }
329
+ if (!listeners.size)
330
+ this.#listeners.delete(event);
331
+ //The last listener gone (a once listener called): the stream is closed
332
+ if (!this.#listeners.size)
333
+ abort.abort();
334
+ }
335
+ }
336
+ else {
337
+ await response.body?.cancel();
338
+ }
339
+ }
340
+ catch {
341
+ //The service is gone: listened again after the delay
342
+ }
343
+ if (abort.signal.aborted || this.#destroyed)
344
+ break;
345
+ await new Promise((resolve) => setTimeout(resolve, this.reconnectDelay));
346
+ }
347
+ if (this.#events === abort)
348
+ this.#events = undefined;
349
+ }
350
+ }
351
+ __decorate([
352
+ Configurable(DTO.String().uri({ scheme: ['http', 'https'] }).required()),
353
+ __metadata("design:type", String)
354
+ ], HTTPServiceProxy.prototype, "url", void 0);
355
+ __decorate([
356
+ Configurable(DTO.Object().pattern(DTO.String(), DTO.String()).optional()),
357
+ __metadata("design:type", Object)
358
+ ], HTTPServiceProxy.prototype, "headers", void 0);
359
+ __decorate([
360
+ Configurable(DTO.String().max(200).optional()),
361
+ __metadata("design:type", String)
362
+ ], HTTPServiceProxy.prototype, "clientId", void 0);
363
+ __decorate([
364
+ Configurable(DTO.Number().integer().min(0).optional().default(1000)),
365
+ __metadata("design:type", Number)
366
+ ], HTTPServiceProxy.prototype, "reconnectDelay", void 0);
@@ -4,8 +4,11 @@ import { ServiceChannel } from '../../channel/BuiltinChannels.js';
4
4
  import { JSONRPCErrorCode, JSONRPCProtocol } from './JSONRPCProtocol.js';
5
5
  import { applicationOf } from '../../lib/ApplicationOf.js';
6
6
  import { trackConnections } from '../../lib/Connections.js';
7
+ import { readBody } from '../../lib/ReadBody.js';
8
+ import { preflightHeaders } from '../../lib/Preflight.js';
7
9
  /**
8
- * The headers of the responses of HTTP: the tools of the browsers may call the adapter
10
+ * The headers of the responses of HTTP: the tools of the browsers may call the adapter; a preflight is allowed the
11
+ * headers it asks for
9
12
  */
10
13
  const CORS_HEADERS = {
11
14
  'Access-Control-Allow-Origin': '*',
@@ -22,22 +25,6 @@ function clientIdOf(request) {
22
25
  const id = Array.isArray(given) ? given[0] : given ?? undefined;
23
26
  return id && id.length <= 200 ? id : undefined;
24
27
  }
25
- /**
26
- * The body of a request, up to a limit
27
- * @param request
28
- * @param limit
29
- */
30
- async function readBody(request, limit) {
31
- const chunks = [];
32
- let size = 0;
33
- for await (const chunk of request) {
34
- size += chunk.length;
35
- if (size > limit)
36
- throw Object.assign(new Error(`The body of the request is larger than ${limit} bytes`), { status: 413 });
37
- chunks.push(chunk);
38
- }
39
- return Buffer.concat(chunks).toString('utf8');
40
- }
41
28
  /**
42
29
  * The messages of a stream framed by lines, or by their Content-Length headers. A message larger than the limit is
43
30
  * skipped (the rest of its line, or the length its header declares, keeping the framing) and reported
@@ -130,11 +117,12 @@ function readMessages(input, framing, limit, receive, ended, oversized) {
130
117
  * validation, otherwise `SERVER_ERROR` with the whole error object as its `data`.
131
118
  *
132
119
  * Over HTTP, a message is the body of a `POST` to the path, answered with 200 and the JSON response (204 when it held
133
- * only notifications); any other method or path is answered with 404, and the CORS headers allow any origin. Over
134
- * WebSocket and stdio, the events pushed by `Entrypoint.notify` and `Entrypoint.broadcast` are sent as notifications,
135
- * `{"jsonrpc": "2.0", "method": event, "params": args}`. The client id carried by `ServiceContext.clientId` is the `id`
136
- * header or `id` query parameter of the HTTP request or of the WebSocket connection (at most 200 characters; a random
137
- * one for a WebSocket connection without it), and `'stdio'` on stdio.
120
+ * only notifications); any other method or path is answered with 404. The CORS headers allow any origin, and the
121
+ * headers a preflight asks for (`Authorization`…). Over WebSocket and stdio, the events pushed by `Entrypoint.notify`
122
+ * and `Entrypoint.broadcast` are sent as notifications, `{"jsonrpc": "2.0", "method": event, "params": args}`. The
123
+ * client id carried by `ServiceContext.clientId` is the `id` header or `id` query parameter of the HTTP request or of
124
+ * the WebSocket connection (at most 200 characters; a random one for a WebSocket connection without it), and `'stdio'`
125
+ * on stdio.
138
126
  *
139
127
  * When an HTTP client disconnects before its response, the action's abort signal fires. When the entrypoint stops,
140
128
  * stdio stops reading, the new requests are answered with `STOPPING`, and once the requests in progress are answered
@@ -170,7 +158,8 @@ function readMessages(input, framing, limit, receive, ended, oversized) {
170
158
  * jsonRPCServiceEntrypoint({name: 'rpc-ws', transport: 'websocket', port: 3001, path: '/rpc'})
171
159
  * ]
172
160
  * })
173
- * }
161
+ * },
162
+ * bootstrap: ['entrypoint']
174
163
  * })
175
164
  *
176
165
  * //An HTTP client: {"jsonrpc": "2.0", "id": 1, "result": {"id": 1, "name": "Ada"}}
@@ -268,7 +257,7 @@ export function jsonRPCServiceEntrypoint(options = {}) {
268
257
  server = createServer((request, response) => {
269
258
  const url = new URL(request.url ?? '/', 'http://localhost');
270
259
  if (request.method === 'OPTIONS')
271
- return void response.writeHead(204, CORS_HEADERS).end();
260
+ return void response.writeHead(204, preflightHeaders(request, CORS_HEADERS)).end();
272
261
  if (request.method !== 'POST' || url.pathname !== path) {
273
262
  return void response.writeHead(404, { ...CORS_HEADERS, 'Content-Type': 'application/json' }).end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32600, message: `POST ${path} receives the requests of JSON-RPC 2.0` } }));
274
263
  }
@@ -286,7 +275,7 @@ export function jsonRPCServiceEntrypoint(options = {}) {
286
275
  response.writeHead(204, CORS_HEADERS).end();
287
276
  });
288
277
  }, (error) => {
289
- response.writeHead(error.status ?? 400, { ...CORS_HEADERS, 'Content-Type': 'application/json' }).end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32600, message: error.message } }));
278
+ response.writeHead(error.statusCode ?? 400, { ...CORS_HEADERS, 'Content-Type': 'application/json' }).end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32600, message: error.message } }));
290
279
  });
291
280
  });
292
281
  return await listen(server);
@@ -61,7 +61,8 @@ const MAX_DELAY = 2 ** 31 - 1;
61
61
  * onSkip: (action, scheduledAt, reason): void => console.warn(`${action.name} skipped (${reason})`)
62
62
  * })
63
63
  * })
64
- * }
64
+ * },
65
+ * bootstrap: ['entrypoint']
65
66
  * })
66
67
  * ```
67
68
  */
@@ -92,16 +93,22 @@ export function scheduleEntrypoint(options = {}) {
92
93
  timers.add(timer);
93
94
  };
94
95
  /**
95
- * Write a failed run to the log of the application
96
+ * Give a failed run to the onError handler, or write it to the log of the application; an error of the handler is
97
+ * written to the log
96
98
  * @param runtime
97
99
  * @param error
98
100
  * @param action
99
101
  * @param context
100
102
  */
101
103
  const report = async (runtime, error, action, context) => {
102
- if (options.onError)
103
- return await options.onError(error, action, context);
104
- await log(runtime, error, `The scheduled action ${action.name} (${context.schedule}) failed`);
104
+ if (!options.onError)
105
+ return await log(runtime, error, `The scheduled action ${action.name} (${context.schedule}) failed`);
106
+ try {
107
+ await options.onError(error, action, context);
108
+ }
109
+ catch (handlerError) {
110
+ await log(runtime, handlerError, `The onError handler of the scheduled action ${action.name} failed`);
111
+ }
105
112
  };
106
113
  /**
107
114
  * Write an error to the log of the application
@@ -79,7 +79,8 @@ function clientIdOf(socket) {
79
79
  * controllers: [EchoController],
80
80
  * service: socketIOServiceEntrypoint({port: 3000, options: {cors: {origin: '*'}}})
81
81
  * })
82
- * }
82
+ * },
83
+ * bootstrap: ['entrypoint']
83
84
  * })
84
85
  *
85
86
  * //A client, identified as client-1 (the events pushed by entrypoint.notify('client-1', 'progress', 50) reach it)
@@ -1,6 +1,7 @@
1
1
  import { getObjectPropertyPaths, uniqueArray } from '@lakutata/utils';
2
2
  import { defineChannel } from './Channel.js';
3
3
  import { PatternManager } from '../lib/pattern/PatternManager.js';
4
+ import { flattenPattern, flattenSubject } from '../lib/pattern/FlattenPattern.js';
4
5
  import { stringifyPattern } from '../lib/StringifyPattern.js';
5
6
  import { prefixCommand, prefixPattern, prefixRoute } from '../lib/Prefix.js';
6
7
  import { ContextType } from '../context/BaseContext.js';
@@ -74,12 +75,13 @@ export const ServiceChannel = defineChannel({
74
75
  describe: (pattern) => ({ pattern: pattern }),
75
76
  prefix: (prefix, pattern) => prefix.pattern === undefined ? undefined : prefixPattern(prefix.pattern, pattern),
76
77
  createMatcher: (entries) => {
78
+ //The values of the nested objects are compared one by one: {res: {name: 'user'}} matches as {'res.name': 'user'}
77
79
  const patterns = new PatternManager();
78
80
  for (const entry of entries)
79
- patterns.add(entry.details.pattern, entry);
81
+ patterns.add(flattenPattern(entry.details.pattern), entry);
80
82
  return {
81
83
  match: (context) => {
82
- const entry = patterns.find(context.input);
84
+ const entry = patterns.find(flattenSubject(context.input));
83
85
  return entry ? { entry: entry } : undefined;
84
86
  }
85
87
  };
@@ -32,8 +32,9 @@ function ScheduleAddress(schedule) {
32
32
  address.schedule = `every ${typeof options.every === 'number' ? `${options.every}ms` : options.every}`;
33
33
  }
34
34
  else if (options.cron !== undefined) {
35
- //The expression is checked now, not at the first run
36
- new Cron(options.cron, options.timezone);
35
+ //The expression is checked now, not at the first run; a valid one matching no date (February 30th) would never run
36
+ if (!new Cron(options.cron, options.timezone).next())
37
+ throw new SyntaxError(`The cron expression ${options.cron} matches no date`);
37
38
  address.cron = options.cron;
38
39
  address.schedule = `cron ${options.cron}`;
39
40
  if (options.timezone)
@@ -4,10 +4,15 @@ import { Exception } from '@lakutata/core';
4
4
  * Its errno is `E_ACCESS_DENY`, its HTTP status 403.
5
5
  */
6
6
  export class AccessDeniedException extends Exception {
7
- constructor() {
8
- super(...arguments);
7
+ /**
8
+ * Create the exception: its message is "Access denied" unless one is given.
9
+ * @param template The message, or a template filled from the parameters (see `Exception`), or an error whose
10
+ * message it takes; "Access denied" by default.
11
+ * @param params The parameters of the template.
12
+ */
13
+ constructor(template, ...params) {
14
+ super((template ?? 'Access denied'), ...params);
9
15
  this.errno = 'E_ACCESS_DENY';
10
- this.message = 'Access denied';
11
16
  this.statusCode = 403;
12
17
  }
13
18
  }
@@ -1,7 +1,8 @@
1
1
  import { Exception } from '@lakutata/core';
2
2
  /**
3
3
  * The request arrived while the entrypoint is stopping (the application exiting): it is refused, the requests in
4
- * progress being waited for. Its errno is `E_ENTRYPOINT_STOPPED`, its HTTP status 503.
4
+ * progress being waited for. A request still running when the entrypoint's `shutdownTimeout` runs out is aborted with
5
+ * it too. Its errno is `E_ENTRYPOINT_STOPPED`, its HTTP status 503.
5
6
  */
6
7
  export class EntrypointStoppedException extends Exception {
7
8
  constructor() {
@@ -1,4 +1,8 @@
1
1
  import { Exception } from '@lakutata/core';
2
+ /**
3
+ * An action is in a group that the `*ActionGroups` option of its channel (`httpActionGroups`, `cliActionGroups`,
4
+ * `serviceActionGroups`) does not declare, when the entrypoint initializes. Its errno is `E_INVALID_ACTION_GROUP`.
5
+ */
2
6
  export class InvalidActionGroupException extends Exception {
3
7
  constructor() {
4
8
  super(...arguments);
@@ -0,0 +1,14 @@
1
+ import { Exception } from '@lakutata/core';
2
+ /**
3
+ * A service called by an `HTTPServiceProxy` gave no response: it could not be reached, it did not answer in time, or
4
+ * its answer was not a service response (the error page of a proxy in front of it…). Its `cause` is the error of the
5
+ * request when there is one, its `service` the address of the service. Its errno is `E_SERVICE_UNAVAILABLE`, its HTTP
6
+ * status 503. The failures of the actions themselves are thrown as `ServiceInvokeException`.
7
+ */
8
+ export class ServiceUnavailableException extends Exception {
9
+ constructor() {
10
+ super(...arguments);
11
+ this.errno = 'E_SERVICE_UNAVAILABLE';
12
+ this.statusCode = 503;
13
+ }
14
+ }
@@ -3,6 +3,7 @@ export * from '../lib/Controller.js';
3
3
  export * from '../lib/AccessControlRule.js';
4
4
  export * from '../exceptions/AccessDeniedException.js';
5
5
  export * from '../exceptions/DuplicateActionNameException.js';
6
+ export * from '../exceptions/InvalidActionGroupException.js';
6
7
  export * from '../exceptions/ControllerActionNotFoundException.js';
7
8
  export { AccessControl } from '../lib/AccessControl.js';
8
9
  export { Middleware } from '../lib/Middleware.js';
@@ -19,4 +20,6 @@ export * from '../exceptions/EntrypointStoppedException.js';
19
20
  export * from '../exceptions/ServiceInvokeException.js';
20
21
  export { scheduleEntrypoint } from '../adapter/schedule/ScheduleEntrypoint.js';
21
22
  export { httpServiceEntrypoint, httpRequestOfContext } from '../adapter/http/HTTPServiceEntrypoint.js';
23
+ export { buildHTTPServiceProxy, HTTPServiceProxy } from '../adapter/http/HTTPServiceProxy.js';
24
+ export * from '../exceptions/ServiceUnavailableException.js';
22
25
  export { Cron, parseDuration } from '../lib/schedule/Cron.js';
@@ -49,7 +49,8 @@ import { As, isSubclassOf } from '@lakutata/utils';
49
49
  * name: 'Report',
50
50
  * components: {
51
51
  * entrypoint: {class: AuditedEntrypoint, controllers: [ReportController]}
52
- * }
52
+ * },
53
+ * bootstrap: ['entrypoint']
53
54
  * }))
54
55
  * ```
55
56
  */
@@ -48,7 +48,8 @@ import { Configurable } from '@lakutata/core/decorator/di';
48
48
  * rules: [AdminRule],
49
49
  * httpActionGroups: {admin: 'Administration'}
50
50
  * })
51
- * }
51
+ * },
52
+ * bootstrap: ['entrypoint']
52
53
  * }))
53
54
  * ```
54
55
  */
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The responses whose status was set by the action (a StatusResult): the default responders keep it, 200 included
3
+ */
4
+ const explicitStatuses = new WeakSet();
5
+ /**
6
+ * Mark the status of a response as set by the action
7
+ * @param response
8
+ */
9
+ export function markExplicitStatus(response) {
10
+ explicitStatuses.add(response);
11
+ }
12
+ /**
13
+ * Whether the status of a response was set by the action
14
+ * @param response
15
+ */
16
+ export function hasExplicitStatus(response) {
17
+ return explicitStatuses.has(response);
18
+ }
@@ -46,7 +46,8 @@ import { MiddlewareNextCalledException } from '../exceptions/MiddlewareNextCalle
46
46
  * controllers: [StatusController],
47
47
  * middlewares: [Timing]
48
48
  * })
49
- * }
49
+ * },
50
+ * bootstrap: ['entrypoint']
50
51
  * }))
51
52
  * ```
52
53
  */