@3sixty/api-core-server 0.20.0-next.0 → 0.20.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +20 -2
- package/dist/es/baseServer.js +666 -0
- package/dist/es/baseServer.js.map +1 -0
- package/dist/es/index.js +8 -0
- package/dist/es/index.js.map +1 -0
- package/dist/es/models/IBaseServerConstructorOptions.js +2 -0
- package/dist/es/models/IBaseServerConstructorOptions.js.map +1 -0
- package/dist/es/models/IRestProcessorChains.js +2 -0
- package/dist/es/models/IRestProcessorChains.js.map +1 -0
- package/dist/es/models/IServerCorsOptions.js +2 -0
- package/dist/es/models/IServerCorsOptions.js.map +1 -0
- package/dist/es/models/ISocketProcessorChains.js +2 -0
- package/dist/es/models/ISocketProcessorChains.js.map +1 -0
- package/dist/types/baseServer.d.ts +232 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/models/IBaseServerConstructorOptions.d.ts +19 -0
- package/dist/types/models/IRestProcessorChains.d.ts +18 -0
- package/dist/types/models/IServerCorsOptions.d.ts +26 -0
- package/dist/types/models/ISocketProcessorChains.d.ts +26 -0
- package/docs/changelog.md +16 -0
- package/docs/examples.md +78 -0
- package/docs/reference/classes/BaseServer.md +730 -0
- package/docs/reference/index.md +12 -0
- package/docs/reference/interfaces/IBaseServerConstructorOptions.md +33 -0
- package/docs/reference/interfaces/IRestProcessorChains.md +171 -0
- package/docs/reference/interfaces/IServerCorsOptions.md +43 -0
- package/docs/reference/interfaces/ISocketProcessorChains.md +245 -0
- package/locales/.validate-ignore +3 -0
- package/locales/en.json +33 -0
- package/package.json +54 -6
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
// Copyright 2026 IOTA Stiftung.
|
|
2
|
+
// Copyright 2026 3Sixty Trade Ltd.
|
|
3
|
+
// SPDX-License-Identifier: Apache-2.0.
|
|
4
|
+
import { request as httpRequest, validateHeaderName, validateHeaderValue } from "node:http";
|
|
5
|
+
import { request as httpsRequest } from "node:https";
|
|
6
|
+
import { HealthCategory, HealthStatus, HttpBodyLimit, HttpContextIdKeys, HttpErrorHelper } from "@3sixty/api-models";
|
|
7
|
+
import { JsonLdMimeTypeProcessor } from "@3sixty/api-processors";
|
|
8
|
+
import { ContextIdStore } from "@3sixty/context";
|
|
9
|
+
import { BaseError, ComponentFactory, GeneralError, Is, RandomHelper, Url } from "@3sixty/core";
|
|
10
|
+
import { HeaderHelper, HeaderTypes, HttpMethod, HttpStatusCode, MimeTypes } from "@3sixty/web";
|
|
11
|
+
/**
|
|
12
|
+
* Base class for web servers, containing the transport agnostic route processing.
|
|
13
|
+
* Derived classes supply the transport specific behaviour through the abstract server methods.
|
|
14
|
+
*/
|
|
15
|
+
export class BaseServer {
|
|
16
|
+
/**
|
|
17
|
+
* Runtime name for the class.
|
|
18
|
+
*/
|
|
19
|
+
static CLASS_NAME = "BaseServer";
|
|
20
|
+
/**
|
|
21
|
+
* Default port for running the server.
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
static _DEFAULT_PORT = 3000;
|
|
25
|
+
/**
|
|
26
|
+
* Default host for running the server.
|
|
27
|
+
* @internal
|
|
28
|
+
*/
|
|
29
|
+
static _DEFAULT_HOST = "localhost";
|
|
30
|
+
/**
|
|
31
|
+
* Default named body size limits for routes.
|
|
32
|
+
* @internal
|
|
33
|
+
*/
|
|
34
|
+
static _DEFAULT_BODY_LIMITS = {
|
|
35
|
+
[HttpBodyLimit.Default]: 1048576,
|
|
36
|
+
[HttpBodyLimit.Large]: 26214400
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* The logging component type.
|
|
40
|
+
*/
|
|
41
|
+
_loggingComponentType;
|
|
42
|
+
/**
|
|
43
|
+
* The logging component.
|
|
44
|
+
*/
|
|
45
|
+
_logging;
|
|
46
|
+
/**
|
|
47
|
+
* The options for the server.
|
|
48
|
+
*/
|
|
49
|
+
_options;
|
|
50
|
+
/**
|
|
51
|
+
* Whether the server has been started.
|
|
52
|
+
*/
|
|
53
|
+
_started;
|
|
54
|
+
/**
|
|
55
|
+
* The mime type processors.
|
|
56
|
+
*/
|
|
57
|
+
_mimeTypeProcessors;
|
|
58
|
+
/**
|
|
59
|
+
* Include the stack with errors.
|
|
60
|
+
*/
|
|
61
|
+
_includeErrorStack;
|
|
62
|
+
/**
|
|
63
|
+
* The public origin of the server, used for constructing the request URL and for CORS.
|
|
64
|
+
*/
|
|
65
|
+
_publicOrigin;
|
|
66
|
+
/**
|
|
67
|
+
* The local origin of the server, used for constructing the request URL and for CORS.
|
|
68
|
+
*/
|
|
69
|
+
_localOrigin;
|
|
70
|
+
/**
|
|
71
|
+
* The REST processor chains resolved when the server is built.
|
|
72
|
+
*/
|
|
73
|
+
_restChains;
|
|
74
|
+
/**
|
|
75
|
+
* The socket processor chains resolved when the server is built.
|
|
76
|
+
*/
|
|
77
|
+
_socketChains;
|
|
78
|
+
/**
|
|
79
|
+
* Create a new instance of BaseServer.
|
|
80
|
+
* @param options The options for the server.
|
|
81
|
+
*/
|
|
82
|
+
constructor(options) {
|
|
83
|
+
this._loggingComponentType = options?.loggingComponentType;
|
|
84
|
+
this._logging = ComponentFactory.getIfExists(options?.loggingComponentType);
|
|
85
|
+
this._started = false;
|
|
86
|
+
this._restChains = { pre: [], process: [], post: [] };
|
|
87
|
+
this._socketChains = { connected: [], disconnected: [], pre: [], process: [], post: [] };
|
|
88
|
+
this._mimeTypeProcessors = options?.mimeTypeProcessors ?? [];
|
|
89
|
+
this._includeErrorStack = options?.includeErrorStack ?? false;
|
|
90
|
+
const hasJsonLd = this._mimeTypeProcessors.some(processor => processor.getTypes().includes(MimeTypes.JsonLd));
|
|
91
|
+
if (!hasJsonLd) {
|
|
92
|
+
this._mimeTypeProcessors.push(new JsonLdMimeTypeProcessor());
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Start the server.
|
|
97
|
+
* @returns A promise that resolves when the server is listening for connections.
|
|
98
|
+
*/
|
|
99
|
+
async start() {
|
|
100
|
+
const host = this._options?.host ?? BaseServer._DEFAULT_HOST;
|
|
101
|
+
const port = this._options?.port ?? BaseServer._DEFAULT_PORT;
|
|
102
|
+
await this._logging?.log({
|
|
103
|
+
level: "info",
|
|
104
|
+
ts: Date.now(),
|
|
105
|
+
source: BaseServer.CLASS_NAME,
|
|
106
|
+
message: "starting",
|
|
107
|
+
data: {
|
|
108
|
+
host,
|
|
109
|
+
port
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
if (!this._started) {
|
|
113
|
+
try {
|
|
114
|
+
await this.serverListen(host, port);
|
|
115
|
+
const addresses = this.serverAddresses();
|
|
116
|
+
const protocol = this.serverIsSecure() ? "https://" : "http://";
|
|
117
|
+
const normalizeAnyHost = (address) => {
|
|
118
|
+
if (address === "0.0.0.0") {
|
|
119
|
+
return "127.0.0.1";
|
|
120
|
+
}
|
|
121
|
+
if (address === "::") {
|
|
122
|
+
return "::1";
|
|
123
|
+
}
|
|
124
|
+
return address;
|
|
125
|
+
};
|
|
126
|
+
const formatAddress = (address, family, forceProtocol) => {
|
|
127
|
+
const displayAddress = normalizeAnyHost(address);
|
|
128
|
+
const isIPv6 = family === "IPv6" || (Is.stringValue(displayAddress) && displayAddress.includes(":"));
|
|
129
|
+
return `${forceProtocol ?? protocol}${isIPv6 ? "[" : ""}${displayAddress}${isIPv6 ? "]" : ""}`;
|
|
130
|
+
};
|
|
131
|
+
const startupAddresses = addresses.map(a => {
|
|
132
|
+
const formatted = formatAddress(a.address, a.family, protocol);
|
|
133
|
+
return `${formatted}:${a.port}`;
|
|
134
|
+
});
|
|
135
|
+
for (const origin of [this._localOrigin, this._publicOrigin]) {
|
|
136
|
+
if (Is.stringValue(origin)) {
|
|
137
|
+
const originUrl = Url.tryParseExact(origin);
|
|
138
|
+
if (!Is.empty(originUrl)) {
|
|
139
|
+
const originParts = originUrl.parts();
|
|
140
|
+
const formatted = formatAddress(originParts.host, undefined, `${originParts.schema}://`);
|
|
141
|
+
startupAddresses.push(`${formatted}${Is.integer(originParts.port) ? `:${originParts.port}` : ""}`);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
startupAddresses.push(origin);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const distinctStartupAddresses = [...new Set(startupAddresses)];
|
|
149
|
+
await this._logging?.log({
|
|
150
|
+
level: "info",
|
|
151
|
+
ts: Date.now(),
|
|
152
|
+
source: BaseServer.CLASS_NAME,
|
|
153
|
+
message: "started",
|
|
154
|
+
data: {
|
|
155
|
+
addresses: distinctStartupAddresses.join(", ")
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
this._started = true;
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
await this._logging?.log({
|
|
162
|
+
level: "error",
|
|
163
|
+
ts: Date.now(),
|
|
164
|
+
source: BaseServer.CLASS_NAME,
|
|
165
|
+
message: "startFailed",
|
|
166
|
+
error: BaseError.fromError(err)
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Stop the server.
|
|
173
|
+
* @returns A promise that resolves when the server has shut down all connections.
|
|
174
|
+
*/
|
|
175
|
+
async stop() {
|
|
176
|
+
if (this._started) {
|
|
177
|
+
this._started = false;
|
|
178
|
+
await this.serverClose();
|
|
179
|
+
await this._logging?.log({
|
|
180
|
+
level: "info",
|
|
181
|
+
ts: Date.now(),
|
|
182
|
+
source: BaseServer.CLASS_NAME,
|
|
183
|
+
message: "stopped"
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Returns the health status of the component.
|
|
189
|
+
* @returns The health status of the component, can return multiple entries for elements within the component.
|
|
190
|
+
*/
|
|
191
|
+
async health() {
|
|
192
|
+
let healthCheck;
|
|
193
|
+
if (this.serverIsListening()) {
|
|
194
|
+
healthCheck = {
|
|
195
|
+
source: BaseServer.CLASS_NAME,
|
|
196
|
+
status: HealthStatus.Ok,
|
|
197
|
+
category: HealthCategory.Connectivity,
|
|
198
|
+
description: "healthConnectivityDescription",
|
|
199
|
+
message: "reachable"
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
healthCheck = {
|
|
204
|
+
source: BaseServer.CLASS_NAME,
|
|
205
|
+
status: HealthStatus.Error,
|
|
206
|
+
category: HealthCategory.Connectivity,
|
|
207
|
+
description: "healthConnectivityDescription",
|
|
208
|
+
message: "unreachable"
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return [healthCheck];
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Verify the root endpoint is reachable and returns a body by making a real HTTP request.
|
|
215
|
+
* Skipped when GET / is not registered on this server instance.
|
|
216
|
+
* @param callback The callback to invoke when a deferred health result is ready.
|
|
217
|
+
* @returns The application health status of the component.
|
|
218
|
+
*/
|
|
219
|
+
async healthApplication(callback) {
|
|
220
|
+
if (!this.serverHasRootRoute()) {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
if (!Is.stringValue(this._localOrigin)) {
|
|
224
|
+
return [
|
|
225
|
+
{
|
|
226
|
+
source: BaseServer.CLASS_NAME,
|
|
227
|
+
status: HealthStatus.Error,
|
|
228
|
+
category: HealthCategory.Application,
|
|
229
|
+
description: "healthApplicationDescription",
|
|
230
|
+
message: "serverNotBuilt"
|
|
231
|
+
}
|
|
232
|
+
];
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const response = await this.requestLocalRoot(`${this._localOrigin}/`);
|
|
236
|
+
if (response.ok && Is.stringValue(response.body)) {
|
|
237
|
+
return [
|
|
238
|
+
{
|
|
239
|
+
source: BaseServer.CLASS_NAME,
|
|
240
|
+
status: HealthStatus.Ok,
|
|
241
|
+
category: HealthCategory.Application,
|
|
242
|
+
description: "healthApplicationDescription",
|
|
243
|
+
message: "rootEndpointReachable"
|
|
244
|
+
}
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
return [
|
|
248
|
+
{
|
|
249
|
+
source: BaseServer.CLASS_NAME,
|
|
250
|
+
status: HealthStatus.Error,
|
|
251
|
+
category: HealthCategory.Application,
|
|
252
|
+
description: "healthApplicationDescription",
|
|
253
|
+
message: "rootEndpointError"
|
|
254
|
+
}
|
|
255
|
+
];
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
return [
|
|
259
|
+
{
|
|
260
|
+
source: BaseServer.CLASS_NAME,
|
|
261
|
+
status: HealthStatus.Error,
|
|
262
|
+
category: HealthCategory.Application,
|
|
263
|
+
description: "healthApplicationDescription",
|
|
264
|
+
error: BaseError.fromError(error)
|
|
265
|
+
}
|
|
266
|
+
];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Request the root endpoint of this server over the loopback address.
|
|
271
|
+
* @param url The url of the root endpoint.
|
|
272
|
+
* @returns Whether the response was successful and its body.
|
|
273
|
+
*/
|
|
274
|
+
async requestLocalRoot(url) {
|
|
275
|
+
const isSecure = url.startsWith("https:");
|
|
276
|
+
return new Promise((resolve, reject) => {
|
|
277
|
+
const callback = (response) => {
|
|
278
|
+
const chunks = [];
|
|
279
|
+
response.on("data", (chunk) => chunks.push(chunk));
|
|
280
|
+
response.on("error", reject);
|
|
281
|
+
response.on("end", () => {
|
|
282
|
+
const statusCode = response.statusCode ?? 0;
|
|
283
|
+
resolve({
|
|
284
|
+
ok: statusCode >= HttpStatusCode.ok && statusCode < HttpStatusCode.multipleChoices,
|
|
285
|
+
body: Buffer.concat(chunks).toString("utf8")
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
};
|
|
289
|
+
// The certificate is issued for the public host name rather than the loopback
|
|
290
|
+
// address, and this only checks the server can reach its own listener, so it
|
|
291
|
+
// is not verified
|
|
292
|
+
const request = isSecure
|
|
293
|
+
? httpsRequest(url, { rejectUnauthorized: false }, callback)
|
|
294
|
+
: httpRequest(url, callback);
|
|
295
|
+
request.on("error", reject);
|
|
296
|
+
request.end();
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Perform the transport agnostic part of building the server, validating the routes and
|
|
301
|
+
* processors, resolving the origins and resolving the processor chains.
|
|
302
|
+
* @param restRouteProcessors The processors for incoming requests over REST.
|
|
303
|
+
* @param restRoutes The REST routes.
|
|
304
|
+
* @param socketRouteProcessors The processors for incoming requests over Sockets.
|
|
305
|
+
* @param socketRoutes The socket routes.
|
|
306
|
+
* @param options Options for building the server.
|
|
307
|
+
* @returns A promise that resolves when the shared build steps are complete.
|
|
308
|
+
*/
|
|
309
|
+
async prepareBuild(restRouteProcessors, restRoutes, socketRouteProcessors, socketRoutes, options) {
|
|
310
|
+
if (Is.arrayValue(restRoutes) && !Is.arrayValue(restRouteProcessors)) {
|
|
311
|
+
throw new GeneralError(BaseServer.CLASS_NAME, "noRestProcessors");
|
|
312
|
+
}
|
|
313
|
+
if (Is.arrayValue(socketRoutes) && !Is.arrayValue(socketRouteProcessors)) {
|
|
314
|
+
throw new GeneralError(BaseServer.CLASS_NAME, "noSocketProcessors");
|
|
315
|
+
}
|
|
316
|
+
await this._logging?.log({
|
|
317
|
+
level: "info",
|
|
318
|
+
ts: Date.now(),
|
|
319
|
+
source: BaseServer.CLASS_NAME,
|
|
320
|
+
message: "building"
|
|
321
|
+
});
|
|
322
|
+
let localHost = options?.host ?? "127.0.0.1";
|
|
323
|
+
if (localHost === "0.0.0.0") {
|
|
324
|
+
localHost = "127.0.0.1";
|
|
325
|
+
}
|
|
326
|
+
this._localOrigin = `${this.serverIsSecure() ? "https" : "http"}://${localHost}:${options?.port ?? 3000}`;
|
|
327
|
+
if (Is.stringValue(options?.publicOrigin)) {
|
|
328
|
+
const publicUrl = Url.tryParseExact(options.publicOrigin);
|
|
329
|
+
if (!Is.empty(publicUrl)) {
|
|
330
|
+
const urlParts = publicUrl.parts();
|
|
331
|
+
this._publicOrigin = `${urlParts.schema}://${urlParts.host}${Is.integer(urlParts.port) ? `:${urlParts.port}` : ""}`;
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
throw new GeneralError(BaseServer.CLASS_NAME, "invalidPublicOrigin", {
|
|
335
|
+
publicOrigin: options.publicOrigin
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
this._options = options;
|
|
340
|
+
this.buildProcessorChains(restRouteProcessors, socketRouteProcessors);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Resolve the route processors in to per phase chains, so that the lookups and bindings
|
|
344
|
+
* are performed once when the server is built instead of on every request.
|
|
345
|
+
* @param restRouteProcessors The processors for the incoming REST requests.
|
|
346
|
+
* @param socketRouteProcessors The processors for the incoming socket requests.
|
|
347
|
+
*/
|
|
348
|
+
buildProcessorChains(restRouteProcessors, socketRouteProcessors) {
|
|
349
|
+
const restChains = { pre: [], process: [], post: [] };
|
|
350
|
+
for (const routeProcessor of restRouteProcessors ?? []) {
|
|
351
|
+
const pre = routeProcessor.pre?.bind(routeProcessor);
|
|
352
|
+
if (Is.function(pre)) {
|
|
353
|
+
restChains.pre.push(pre);
|
|
354
|
+
}
|
|
355
|
+
const process = routeProcessor.process?.bind(routeProcessor);
|
|
356
|
+
if (Is.function(process)) {
|
|
357
|
+
restChains.process.push(process);
|
|
358
|
+
}
|
|
359
|
+
const post = routeProcessor.post?.bind(routeProcessor);
|
|
360
|
+
if (Is.function(post)) {
|
|
361
|
+
restChains.post.push(post);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const socketChains = {
|
|
365
|
+
connected: [],
|
|
366
|
+
disconnected: [],
|
|
367
|
+
pre: [],
|
|
368
|
+
process: [],
|
|
369
|
+
post: []
|
|
370
|
+
};
|
|
371
|
+
for (const routeProcessor of socketRouteProcessors ?? []) {
|
|
372
|
+
const connected = routeProcessor.connected?.bind(routeProcessor);
|
|
373
|
+
if (Is.function(connected)) {
|
|
374
|
+
socketChains.connected.push(connected);
|
|
375
|
+
}
|
|
376
|
+
const disconnected = routeProcessor.disconnected?.bind(routeProcessor);
|
|
377
|
+
if (Is.function(disconnected)) {
|
|
378
|
+
socketChains.disconnected.push(disconnected);
|
|
379
|
+
}
|
|
380
|
+
const pre = routeProcessor.pre?.bind(routeProcessor);
|
|
381
|
+
if (Is.function(pre)) {
|
|
382
|
+
socketChains.pre.push(pre);
|
|
383
|
+
}
|
|
384
|
+
const process = routeProcessor.process?.bind(routeProcessor);
|
|
385
|
+
if (Is.function(process)) {
|
|
386
|
+
socketChains.process.push(process);
|
|
387
|
+
}
|
|
388
|
+
const post = routeProcessor.post?.bind(routeProcessor);
|
|
389
|
+
if (Is.function(post)) {
|
|
390
|
+
socketChains.post.push(post);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
this._restChains = restChains;
|
|
394
|
+
this._socketChains = socketChains;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Merge the configured body limits over the built-in ones and validate them.
|
|
398
|
+
* @returns The named body limits in bytes.
|
|
399
|
+
* @throws GeneralError If a limit is not a positive integer.
|
|
400
|
+
*/
|
|
401
|
+
resolveBodyLimits() {
|
|
402
|
+
const bodyLimits = {
|
|
403
|
+
...BaseServer._DEFAULT_BODY_LIMITS,
|
|
404
|
+
...this._options?.bodyLimits
|
|
405
|
+
};
|
|
406
|
+
for (const name of Object.keys(bodyLimits)) {
|
|
407
|
+
const bodyLimit = bodyLimits[name];
|
|
408
|
+
if (!Is.integer(bodyLimit) || bodyLimit <= 0) {
|
|
409
|
+
throw new GeneralError(BaseServer.CLASS_NAME, "invalidBodyLimit", {
|
|
410
|
+
bodyLimit: name,
|
|
411
|
+
value: bodyLimit
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return bodyLimits;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Resolve the CORS options from the web server options, merging in the defaults.
|
|
419
|
+
* @param options The web server options.
|
|
420
|
+
* @returns The resolved CORS options.
|
|
421
|
+
*/
|
|
422
|
+
resolveCorsOptions(options) {
|
|
423
|
+
let origins = ["*"];
|
|
424
|
+
if (Is.arrayValue(options?.corsOrigins)) {
|
|
425
|
+
origins = options?.corsOrigins;
|
|
426
|
+
}
|
|
427
|
+
else if (Is.stringValue(options?.corsOrigins)) {
|
|
428
|
+
origins = [options?.corsOrigins];
|
|
429
|
+
}
|
|
430
|
+
const hasWildcardOrigin = origins.includes("*");
|
|
431
|
+
const methods = options?.methods ?? [
|
|
432
|
+
HttpMethod.GET,
|
|
433
|
+
HttpMethod.PUT,
|
|
434
|
+
HttpMethod.POST,
|
|
435
|
+
HttpMethod.PATCH,
|
|
436
|
+
HttpMethod.DELETE,
|
|
437
|
+
HttpMethod.OPTIONS
|
|
438
|
+
];
|
|
439
|
+
const allowedHeaders = [
|
|
440
|
+
HeaderTypes.AccessControlAllowOrigin,
|
|
441
|
+
HeaderTypes.ContentEncoding,
|
|
442
|
+
HeaderTypes.AcceptEncoding,
|
|
443
|
+
HeaderTypes.ContentType,
|
|
444
|
+
HeaderTypes.Authorization,
|
|
445
|
+
HeaderTypes.Accept
|
|
446
|
+
];
|
|
447
|
+
const exposedHeaders = [HeaderTypes.ContentDisposition, HeaderTypes.Location];
|
|
448
|
+
if (Is.arrayValue(options?.allowedHeaders)) {
|
|
449
|
+
allowedHeaders.push(...options.allowedHeaders);
|
|
450
|
+
}
|
|
451
|
+
if (Is.arrayValue(options?.exposedHeaders)) {
|
|
452
|
+
exposedHeaders.push(...options.exposedHeaders);
|
|
453
|
+
}
|
|
454
|
+
return { origins, hasWildcardOrigin, methods, allowedHeaders, exposedHeaders };
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Build the server request from the transport values, the path params and query must already
|
|
458
|
+
* have been decoded by the transport, as decoding them again corrupts values containing %.
|
|
459
|
+
* @param method The request method.
|
|
460
|
+
* @param url The full request url, including the origin.
|
|
461
|
+
* @param body The request body.
|
|
462
|
+
* @param query The request query params.
|
|
463
|
+
* @param pathParams The request path params.
|
|
464
|
+
* @param headers The request headers.
|
|
465
|
+
* @returns The server request.
|
|
466
|
+
*/
|
|
467
|
+
buildServerRequest(method, url, body, query, pathParams, headers) {
|
|
468
|
+
return {
|
|
469
|
+
method,
|
|
470
|
+
url,
|
|
471
|
+
body,
|
|
472
|
+
query,
|
|
473
|
+
pathParams,
|
|
474
|
+
headers
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Validate the processed response before it is sent, so that every transport fails an invalid
|
|
479
|
+
* response in the same way rather than with its own transport specific error.
|
|
480
|
+
* @param httpResponse The processed response.
|
|
481
|
+
* @throws GeneralError If the status code or a header is invalid.
|
|
482
|
+
*/
|
|
483
|
+
validateResponse(httpResponse) {
|
|
484
|
+
const statusCode = httpResponse.statusCode;
|
|
485
|
+
if (!Is.empty(statusCode) &&
|
|
486
|
+
(!Is.integer(statusCode) || statusCode < 100 || statusCode > 599)) {
|
|
487
|
+
throw new GeneralError(BaseServer.CLASS_NAME, "invalidStatusCode", { statusCode });
|
|
488
|
+
}
|
|
489
|
+
if (!Is.empty(httpResponse.headers)) {
|
|
490
|
+
for (const header of Object.keys(httpResponse.headers)) {
|
|
491
|
+
const value = httpResponse.headers[header];
|
|
492
|
+
try {
|
|
493
|
+
validateHeaderName(header);
|
|
494
|
+
for (const item of Is.array(value) ? value : [value]) {
|
|
495
|
+
if (!Is.empty(item)) {
|
|
496
|
+
validateHeaderValue(header, item);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
catch (err) {
|
|
501
|
+
throw new GeneralError(BaseServer.CLASS_NAME, "invalidHeader", { header }, err);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Build the context ids for an incoming request.
|
|
508
|
+
* @param requestOrigin The origin the request was received on.
|
|
509
|
+
* @param headers The request headers.
|
|
510
|
+
* @returns The context ids.
|
|
511
|
+
*/
|
|
512
|
+
buildContextIds(requestOrigin, headers) {
|
|
513
|
+
return {
|
|
514
|
+
[HttpContextIdKeys.IpAddress]: HeaderHelper.extractClientIps(headers).join("|"),
|
|
515
|
+
[HttpContextIdKeys.UserAgent]: HeaderHelper.extractUserAgent(headers),
|
|
516
|
+
[HttpContextIdKeys.CorrelationId]: HeaderHelper.extractCorrelationId(headers),
|
|
517
|
+
[HttpContextIdKeys.RemoteRequest]: RandomHelper.generateUuidV7("compact"),
|
|
518
|
+
[HttpContextIdKeys.LocalOrigin]: this._localOrigin,
|
|
519
|
+
// This can be overridden by a processor if needed, for example a tenant processor
|
|
520
|
+
[HttpContextIdKeys.PublicOrigin]: this._publicOrigin ?? requestOrigin ?? this._localOrigin
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Run the REST processors for the route.
|
|
525
|
+
* @param restRoute The route to process.
|
|
526
|
+
* @param httpServerRequest The incoming request.
|
|
527
|
+
* @param httpResponse The outgoing response.
|
|
528
|
+
* @param contextIds The context IDs of the request.
|
|
529
|
+
* @param processorState The state handed through the processors.
|
|
530
|
+
*/
|
|
531
|
+
async runProcessorsRest(restRoute, httpServerRequest, httpResponse, contextIds, processorState) {
|
|
532
|
+
let hasPreError = false;
|
|
533
|
+
try {
|
|
534
|
+
// Run inside ContextIdStore.run so pre-processors can do tenant-scoped storage lookups.
|
|
535
|
+
await ContextIdStore.run(contextIds, async () => {
|
|
536
|
+
for (const pre of this._restChains.pre) {
|
|
537
|
+
await pre(httpServerRequest, httpResponse, restRoute, contextIds, processorState, {
|
|
538
|
+
loggingComponentType: this._loggingComponentType
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
catch (err) {
|
|
544
|
+
const { error, httpStatusCode } = HttpErrorHelper.processError(err, this._includeErrorStack);
|
|
545
|
+
HttpErrorHelper.buildResponse(httpResponse, error, httpStatusCode, this._includeErrorStack);
|
|
546
|
+
hasPreError = true;
|
|
547
|
+
}
|
|
548
|
+
// A pre-processor may set an error response without throwing; treat that as halt.
|
|
549
|
+
if (!hasPreError &&
|
|
550
|
+
Is.integer(httpResponse.statusCode) &&
|
|
551
|
+
httpResponse.statusCode >= HttpStatusCode.badRequest) {
|
|
552
|
+
hasPreError = true;
|
|
553
|
+
}
|
|
554
|
+
// Don't run the main processing if there was an error in the pre processing
|
|
555
|
+
// As this is likely to perform tasks such as authentication which may have failed
|
|
556
|
+
if (!hasPreError) {
|
|
557
|
+
try {
|
|
558
|
+
// Run the processors within an async context
|
|
559
|
+
// so that any services can access the context ids
|
|
560
|
+
await ContextIdStore.run(contextIds, async () => {
|
|
561
|
+
for (const process of this._restChains.process) {
|
|
562
|
+
await process(httpServerRequest, httpResponse, restRoute, processorState, {
|
|
563
|
+
loggingComponentType: this._loggingComponentType
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
catch (err) {
|
|
569
|
+
const { error, httpStatusCode } = HttpErrorHelper.processError(err, this._includeErrorStack);
|
|
570
|
+
HttpErrorHelper.buildResponse(httpResponse, error, httpStatusCode, this._includeErrorStack);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
// Always run the post processors, even if there was an error earlier
|
|
575
|
+
// as they may perform cleanup tasks, or logging etc
|
|
576
|
+
await ContextIdStore.run(contextIds, async () => {
|
|
577
|
+
for (const post of this._restChains.post) {
|
|
578
|
+
await post(httpServerRequest, httpResponse, restRoute, contextIds, processorState, {
|
|
579
|
+
loggingComponentType: this._loggingComponentType
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
// Just log post processor errors
|
|
586
|
+
await this._logging?.log({
|
|
587
|
+
level: "error",
|
|
588
|
+
ts: Date.now(),
|
|
589
|
+
source: BaseServer.CLASS_NAME,
|
|
590
|
+
message: "postProcessorError",
|
|
591
|
+
error: BaseError.fromError(err),
|
|
592
|
+
data: {
|
|
593
|
+
route: restRoute?.path ?? ""
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Run the socket processors for the route.
|
|
600
|
+
* @param socketRoute The route to process.
|
|
601
|
+
* @param socketServerRequest The incoming request.
|
|
602
|
+
* @param httpResponse The outgoing response.
|
|
603
|
+
* @param contextIds The context IDs of the request.
|
|
604
|
+
* @param processorState The state handed through the processors.
|
|
605
|
+
* @param requestTopic The topic of the request.
|
|
606
|
+
* @param responseEmitter The emitter to send the response on.
|
|
607
|
+
*/
|
|
608
|
+
async runProcessorsSocket(socketRoute, socketServerRequest, httpResponse, contextIds, processorState, requestTopic, responseEmitter) {
|
|
609
|
+
// Custom emit method which will also call the post processors
|
|
610
|
+
const postProcessEmit = async (topic, response, responseProcessorState) => {
|
|
611
|
+
await responseEmitter(topic, response);
|
|
612
|
+
try {
|
|
613
|
+
// The post processors are called after the response has been emitted
|
|
614
|
+
for (const post of this._socketChains.post) {
|
|
615
|
+
await post(socketServerRequest, response, socketRoute, contextIds, responseProcessorState, {
|
|
616
|
+
loggingComponentType: this._loggingComponentType
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
catch (err) {
|
|
621
|
+
await this._logging?.log({
|
|
622
|
+
level: "error",
|
|
623
|
+
ts: Date.now(),
|
|
624
|
+
source: BaseServer.CLASS_NAME,
|
|
625
|
+
message: "postProcessorError",
|
|
626
|
+
error: BaseError.fromError(err),
|
|
627
|
+
data: {
|
|
628
|
+
route: socketRoute.path
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
try {
|
|
634
|
+
for (const pre of this._socketChains.pre) {
|
|
635
|
+
await pre(socketServerRequest, httpResponse, socketRoute, contextIds, processorState, {
|
|
636
|
+
loggingComponentType: this._loggingComponentType
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
// We always call all the processors regardless of any response set by a previous processor.
|
|
640
|
+
// But if a pre processor sets a status code, we will emit the response manually, as the pre
|
|
641
|
+
// and post processors do not receive the emit method, they just populate the response object.
|
|
642
|
+
if (!Is.empty(httpResponse.statusCode)) {
|
|
643
|
+
await postProcessEmit(requestTopic, httpResponse, processorState);
|
|
644
|
+
}
|
|
645
|
+
await ContextIdStore.run(contextIds, async () => {
|
|
646
|
+
for (const process of this._socketChains.process) {
|
|
647
|
+
await process(socketServerRequest, httpResponse, socketRoute, processorState, async (topic, processResponse) => {
|
|
648
|
+
await postProcessEmit(topic, processResponse, processorState);
|
|
649
|
+
}, this._loggingComponentType);
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
// If the processors set the status to any kind of error then we should emit this manually
|
|
653
|
+
if (Is.integer(httpResponse.statusCode) &&
|
|
654
|
+
httpResponse.statusCode >= HttpStatusCode.badRequest) {
|
|
655
|
+
await postProcessEmit(requestTopic, httpResponse, processorState);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
catch (err) {
|
|
659
|
+
// Emit any unhandled errors manually
|
|
660
|
+
const { error, httpStatusCode } = HttpErrorHelper.processError(err, this._includeErrorStack);
|
|
661
|
+
HttpErrorHelper.buildResponse(httpResponse, error, httpStatusCode, this._includeErrorStack);
|
|
662
|
+
await postProcessEmit(requestTopic, httpResponse, processorState);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
//# sourceMappingURL=baseServer.js.map
|