@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.
- package/README.md +39 -26
- package/README.zh-CN.md +182 -0
- package/dist/cjs/Entrypoint.d.ts +13 -7
- package/dist/cjs/Entrypoint.js +12 -6
- package/dist/cjs/adapter/commander/CommanderCLIEntrypoint.d.ts +3 -2
- package/dist/cjs/adapter/commander/CommanderCLIEntrypoint.js +3 -2
- package/dist/cjs/adapter/electron/ElectronIPCServiceEntrypoint.d.ts +2 -1
- package/dist/cjs/adapter/electron/ElectronIPCServiceEntrypoint.js +2 -1
- package/dist/cjs/adapter/express/ExpressHTTPEntrypoint.d.ts +3 -2
- package/dist/cjs/adapter/express/ExpressHTTPEntrypoint.js +8 -2
- package/dist/cjs/adapter/express/Responders.d.ts +2 -1
- package/dist/cjs/adapter/express/Responders.js +4 -2
- package/dist/cjs/adapter/express/StaticUI.d.ts +2 -1
- package/dist/cjs/adapter/express/StaticUI.js +2 -1
- package/dist/cjs/adapter/fastify/FastifyHTTPEntrypoint.d.ts +2 -1
- package/dist/cjs/adapter/fastify/FastifyHTTPEntrypoint.js +4 -1
- package/dist/cjs/adapter/fastify/Responders.d.ts +2 -1
- package/dist/cjs/adapter/fastify/Responders.js +4 -2
- package/dist/cjs/adapter/http/HTTPServiceEntrypoint.d.ts +4 -3
- package/dist/cjs/adapter/http/HTTPServiceEntrypoint.js +10 -22
- package/dist/cjs/adapter/http/HTTPServiceProxy.d.ts +190 -0
- package/dist/cjs/adapter/http/HTTPServiceProxy.js +371 -0
- package/dist/cjs/adapter/jsonrpc/JSONRPCServiceEntrypoint.d.ts +11 -7
- package/dist/cjs/adapter/jsonrpc/JSONRPCServiceEntrypoint.js +15 -26
- package/dist/cjs/adapter/schedule/ScheduleEntrypoint.d.ts +2 -1
- package/dist/cjs/adapter/schedule/ScheduleEntrypoint.js +12 -5
- package/dist/cjs/adapter/socket.io/SocketIOServiceEntrypoint.d.ts +2 -1
- package/dist/cjs/adapter/socket.io/SocketIOServiceEntrypoint.js +2 -1
- package/dist/cjs/channel/BuiltinChannels.js +4 -2
- package/dist/cjs/channel/EntrypointAdapter.d.ts +2 -2
- package/dist/cjs/decorators/HTTPAction.d.ts +5 -5
- package/dist/cjs/decorators/Schedule.d.ts +4 -2
- package/dist/cjs/decorators/Schedule.js +3 -2
- package/dist/cjs/decorators/ServiceAction.d.ts +12 -8
- package/dist/cjs/decorators/http/DELETE.d.ts +5 -5
- package/dist/cjs/decorators/http/GET.d.ts +7 -7
- package/dist/cjs/decorators/http/HEAD.d.ts +9 -9
- package/dist/cjs/decorators/http/OPTIONS.d.ts +8 -7
- package/dist/cjs/decorators/http/PATCH.d.ts +5 -5
- package/dist/cjs/decorators/http/POST.d.ts +5 -5
- package/dist/cjs/decorators/http/PUT.d.ts +7 -7
- package/dist/cjs/exceptions/AccessDeniedException.d.ts +7 -1
- package/dist/cjs/exceptions/AccessDeniedException.js +8 -3
- package/dist/cjs/exceptions/EntrypointStoppedException.d.ts +2 -1
- package/dist/cjs/exceptions/EntrypointStoppedException.js +2 -1
- package/dist/cjs/exceptions/InvalidActionGroupException.d.ts +4 -0
- package/dist/cjs/exceptions/InvalidActionGroupException.js +4 -0
- package/dist/cjs/exceptions/ServiceUnavailableException.d.ts +15 -0
- package/dist/cjs/exceptions/ServiceUnavailableException.js +18 -0
- package/dist/cjs/exports/Entrypoint.d.ts +3 -0
- package/dist/cjs/exports/Entrypoint.js +6 -1
- package/dist/cjs/lib/AccessControl.d.ts +2 -1
- package/dist/cjs/lib/AccessControl.js +2 -1
- package/dist/cjs/lib/AccessControlRule.d.ts +2 -1
- package/dist/cjs/lib/AccessControlRule.js +2 -1
- package/dist/cjs/lib/ExplicitStatus.d.ts +11 -0
- package/dist/cjs/lib/ExplicitStatus.js +22 -0
- package/dist/cjs/lib/Middleware.d.ts +2 -1
- package/dist/cjs/lib/Middleware.js +2 -1
- package/dist/cjs/lib/Preflight.d.ts +8 -0
- package/dist/cjs/lib/Preflight.js +13 -0
- package/dist/cjs/lib/ReadBody.d.ts +9 -0
- package/dist/cjs/lib/ReadBody.js +25 -0
- package/dist/cjs/lib/pattern/FlattenPattern.d.ts +14 -0
- package/dist/cjs/lib/pattern/FlattenPattern.js +59 -0
- package/dist/esm/Entrypoint.js +12 -6
- package/dist/esm/adapter/commander/CommanderCLIEntrypoint.js +3 -2
- package/dist/esm/adapter/electron/ElectronIPCServiceEntrypoint.js +2 -1
- package/dist/esm/adapter/express/ExpressHTTPEntrypoint.js +8 -2
- package/dist/esm/adapter/express/Responders.js +4 -2
- package/dist/esm/adapter/express/StaticUI.js +2 -1
- package/dist/esm/adapter/fastify/FastifyHTTPEntrypoint.js +4 -1
- package/dist/esm/adapter/fastify/Responders.js +4 -2
- package/dist/esm/adapter/http/HTTPServiceEntrypoint.js +9 -21
- package/dist/esm/adapter/http/HTTPServiceProxy.js +366 -0
- package/dist/esm/adapter/jsonrpc/JSONRPCServiceEntrypoint.js +14 -25
- package/dist/esm/adapter/schedule/ScheduleEntrypoint.js +12 -5
- package/dist/esm/adapter/socket.io/SocketIOServiceEntrypoint.js +2 -1
- package/dist/esm/channel/BuiltinChannels.js +4 -2
- package/dist/esm/decorators/Schedule.js +3 -2
- package/dist/esm/exceptions/AccessDeniedException.js +8 -3
- package/dist/esm/exceptions/EntrypointStoppedException.js +2 -1
- package/dist/esm/exceptions/InvalidActionGroupException.js +4 -0
- package/dist/esm/exceptions/ServiceUnavailableException.js +14 -0
- package/dist/esm/exports/Entrypoint.js +3 -0
- package/dist/esm/lib/AccessControl.js +2 -1
- package/dist/esm/lib/AccessControlRule.js +2 -1
- package/dist/esm/lib/ExplicitStatus.js +18 -0
- package/dist/esm/lib/Middleware.js +2 -1
- package/dist/esm/lib/Preflight.js +10 -0
- package/dist/esm/lib/ReadBody.js +22 -0
- package/dist/esm/lib/pattern/FlattenPattern.js +55 -0
- package/dist/types/Entrypoint.d.ts +13 -7
- package/dist/types/adapter/commander/CommanderCLIEntrypoint.d.ts +3 -2
- package/dist/types/adapter/electron/ElectronIPCServiceEntrypoint.d.ts +2 -1
- package/dist/types/adapter/express/ExpressHTTPEntrypoint.d.ts +3 -2
- package/dist/types/adapter/express/Responders.d.ts +2 -1
- package/dist/types/adapter/express/StaticUI.d.ts +2 -1
- package/dist/types/adapter/fastify/FastifyHTTPEntrypoint.d.ts +2 -1
- package/dist/types/adapter/fastify/Responders.d.ts +2 -1
- package/dist/types/adapter/http/HTTPServiceEntrypoint.d.ts +4 -3
- package/dist/types/adapter/http/HTTPServiceProxy.d.ts +190 -0
- package/dist/types/adapter/jsonrpc/JSONRPCServiceEntrypoint.d.ts +11 -7
- package/dist/types/adapter/schedule/ScheduleEntrypoint.d.ts +2 -1
- package/dist/types/adapter/socket.io/SocketIOServiceEntrypoint.d.ts +2 -1
- package/dist/types/channel/EntrypointAdapter.d.ts +2 -2
- package/dist/types/decorators/HTTPAction.d.ts +5 -5
- package/dist/types/decorators/Schedule.d.ts +4 -2
- package/dist/types/decorators/ServiceAction.d.ts +12 -8
- package/dist/types/decorators/http/DELETE.d.ts +5 -5
- package/dist/types/decorators/http/GET.d.ts +7 -7
- package/dist/types/decorators/http/HEAD.d.ts +9 -9
- package/dist/types/decorators/http/OPTIONS.d.ts +8 -7
- package/dist/types/decorators/http/PATCH.d.ts +5 -5
- package/dist/types/decorators/http/POST.d.ts +5 -5
- package/dist/types/decorators/http/PUT.d.ts +7 -7
- package/dist/types/exceptions/AccessDeniedException.d.ts +7 -1
- package/dist/types/exceptions/EntrypointStoppedException.d.ts +2 -1
- package/dist/types/exceptions/InvalidActionGroupException.d.ts +4 -0
- package/dist/types/exceptions/ServiceUnavailableException.d.ts +15 -0
- package/dist/types/exports/Entrypoint.d.ts +3 -0
- package/dist/types/lib/AccessControl.d.ts +2 -1
- package/dist/types/lib/AccessControlRule.d.ts +2 -1
- package/dist/types/lib/ExplicitStatus.d.ts +11 -0
- package/dist/types/lib/Middleware.d.ts +2 -1
- package/dist/types/lib/Preflight.d.ts +8 -0
- package/dist/types/lib/ReadBody.d.ts +9 -0
- package/dist/types/lib/pattern/FlattenPattern.d.ts +14 -0
- 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
|
|
134
|
-
* WebSocket and stdio, the events pushed by `Entrypoint.notify`
|
|
135
|
-
* `{"jsonrpc": "2.0", "method": event, "params": args}`. The
|
|
136
|
-
* header or `id` query parameter of the HTTP request or of
|
|
137
|
-
* one for a WebSocket connection without it), and `'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.
|
|
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
|
-
*
|
|
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
|
|
104
|
-
|
|
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
|
-
|
|
8
|
-
|
|
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.
|
|
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';
|
|
@@ -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
|
+
}
|