@3sixty/api-server-native 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/index.js +6 -0
- package/dist/es/index.js.map +1 -0
- package/dist/es/models/INativeWebServerConfig.js +2 -0
- package/dist/es/models/INativeWebServerConfig.js.map +1 -0
- package/dist/es/models/INativeWebServerConstructorOptions.js +2 -0
- package/dist/es/models/INativeWebServerConstructorOptions.js.map +1 -0
- package/dist/es/nativeWebServer.js +863 -0
- package/dist/es/nativeWebServer.js.map +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/models/INativeWebServerConfig.d.ts +33 -0
- package/dist/types/models/INativeWebServerConstructorOptions.d.ts +19 -0
- package/dist/types/nativeWebServer.d.ts +74 -0
- package/docs/changelog.md +18 -0
- package/docs/examples.md +70 -0
- package/docs/reference/classes/NativeWebServer.md +834 -0
- package/docs/reference/index.md +10 -0
- package/docs/reference/interfaces/INativeWebServerConfig.md +45 -0
- package/docs/reference/interfaces/INativeWebServerConstructorOptions.md +27 -0
- package/locales/en.json +25 -0
- package/package.json +53 -6
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
// Copyright 2026 3Sixty Trade Ltd.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0.
|
|
3
|
+
import { lookup } from "node:dns/promises";
|
|
4
|
+
import { once } from "node:events";
|
|
5
|
+
import { createServer as createHttpServer } from "node:http";
|
|
6
|
+
import { createServer as createHttpsServer } from "node:https";
|
|
7
|
+
import { createServer as createNetServer } from "node:net";
|
|
8
|
+
import { parse as parseQuery } from "node:querystring";
|
|
9
|
+
import { Readable } from "node:stream";
|
|
10
|
+
import { pipeline } from "node:stream/promises";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
import zlib from "node:zlib";
|
|
13
|
+
import { BaseServer } from "@3sixty/api-core-server";
|
|
14
|
+
import { HttpBodyLimit, HttpErrorHelper } from "@3sixty/api-models";
|
|
15
|
+
import { BaseError, Converter, GeneralError, Is, StringHelper } from "@3sixty/core";
|
|
16
|
+
import { HeaderTypes, HttpMethod, HttpStatusCode, MimeTypes } from "@3sixty/web";
|
|
17
|
+
import Router from "find-my-way";
|
|
18
|
+
import { Server } from "socket.io";
|
|
19
|
+
/**
|
|
20
|
+
* Implementation of the web server using the Node.js HTTP server and the find-my-way router.
|
|
21
|
+
*/
|
|
22
|
+
export class NativeWebServer extends BaseServer {
|
|
23
|
+
/**
|
|
24
|
+
* Runtime name for the class.
|
|
25
|
+
*/
|
|
26
|
+
static CLASS_NAME = "NativeWebServer";
|
|
27
|
+
/**
|
|
28
|
+
* Matches the content types which are worth compressing.
|
|
29
|
+
* @internal
|
|
30
|
+
*/
|
|
31
|
+
static _COMPRESSIBLE_TYPES = /^\s*(?:text\/(?!event-stream)|application\/(?:json|javascript|xml|x-www-form-urlencoded)|[^;\s]+\+(?:json|xml|text|yaml))/i;
|
|
32
|
+
/**
|
|
33
|
+
* The minimum size in bytes of a response body before it is compressed.
|
|
34
|
+
* @internal
|
|
35
|
+
*/
|
|
36
|
+
static _COMPRESS_THRESHOLD = 1024;
|
|
37
|
+
/**
|
|
38
|
+
* The supported response encodings in order of preference, matching the Fastify defaults.
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
41
|
+
static _ENCODINGS = ["zstd", "br", "gzip", "deflate"];
|
|
42
|
+
/**
|
|
43
|
+
* The Brotli options, matching the Fastify defaults as the zlib defaults are very slow.
|
|
44
|
+
* @internal
|
|
45
|
+
*/
|
|
46
|
+
static _BROTLI_OPTIONS = {
|
|
47
|
+
params: {
|
|
48
|
+
[zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
|
|
49
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: 4
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* The compressors for each encoding.
|
|
54
|
+
* @internal
|
|
55
|
+
*/
|
|
56
|
+
static _COMPRESSORS = {
|
|
57
|
+
zstd: promisify(zlib.zstdCompress),
|
|
58
|
+
br: async (body) => promisify(zlib.brotliCompress)(body, NativeWebServer._BROTLI_OPTIONS),
|
|
59
|
+
gzip: promisify(zlib.gzip),
|
|
60
|
+
deflate: promisify(zlib.deflate)
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* The stream compressors for each encoding.
|
|
64
|
+
* @internal
|
|
65
|
+
*/
|
|
66
|
+
static _STREAM_COMPRESSORS = {
|
|
67
|
+
zstd: () => zlib.createZstdCompress(),
|
|
68
|
+
br: () => zlib.createBrotliCompress(NativeWebServer._BROTLI_OPTIONS),
|
|
69
|
+
gzip: () => zlib.createGzip(),
|
|
70
|
+
deflate: () => zlib.createDeflate()
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* The HTTP status codes for the errors raised by the transport.
|
|
74
|
+
* @internal
|
|
75
|
+
*/
|
|
76
|
+
static _ERROR_STATUS_CODES = new WeakMap();
|
|
77
|
+
/**
|
|
78
|
+
* The router.
|
|
79
|
+
* @internal
|
|
80
|
+
*/
|
|
81
|
+
_router;
|
|
82
|
+
/**
|
|
83
|
+
* The maximum length of a path param.
|
|
84
|
+
* @internal
|
|
85
|
+
*/
|
|
86
|
+
_maxParamLength;
|
|
87
|
+
/**
|
|
88
|
+
* The options for the node web server.
|
|
89
|
+
* @internal
|
|
90
|
+
*/
|
|
91
|
+
_webConfig;
|
|
92
|
+
/**
|
|
93
|
+
* The options for the socket server.
|
|
94
|
+
* @internal
|
|
95
|
+
*/
|
|
96
|
+
_socketConfig;
|
|
97
|
+
/**
|
|
98
|
+
* The mime type processors keyed by the media type they handle.
|
|
99
|
+
* @internal
|
|
100
|
+
*/
|
|
101
|
+
_mimeTypeProcessorMap;
|
|
102
|
+
/**
|
|
103
|
+
* The body limit for requests which do not match a route.
|
|
104
|
+
* @internal
|
|
105
|
+
*/
|
|
106
|
+
_defaultBodyLimit;
|
|
107
|
+
/**
|
|
108
|
+
* The resolved CORS settings, available once built.
|
|
109
|
+
* @internal
|
|
110
|
+
*/
|
|
111
|
+
_cors;
|
|
112
|
+
/**
|
|
113
|
+
* The node server, available once listening.
|
|
114
|
+
* @internal
|
|
115
|
+
*/
|
|
116
|
+
_server;
|
|
117
|
+
/**
|
|
118
|
+
* Listeners for additional bound addresses, which forward connections to the main server.
|
|
119
|
+
* @internal
|
|
120
|
+
*/
|
|
121
|
+
_additionalServers;
|
|
122
|
+
/**
|
|
123
|
+
* The socket server, available once built with socket routes.
|
|
124
|
+
* @internal
|
|
125
|
+
*/
|
|
126
|
+
_socketServer;
|
|
127
|
+
/**
|
|
128
|
+
* Create a new instance of NativeWebServer.
|
|
129
|
+
* @param options The options for the server.
|
|
130
|
+
*/
|
|
131
|
+
constructor(options) {
|
|
132
|
+
super({
|
|
133
|
+
loggingComponentType: options?.loggingComponentType,
|
|
134
|
+
mimeTypeProcessors: options?.mimeTypeProcessors,
|
|
135
|
+
includeErrorStack: options?.config?.includeErrorStack
|
|
136
|
+
});
|
|
137
|
+
this._webConfig = options?.config?.web;
|
|
138
|
+
this._maxParamLength = this._webConfig?.maxParamLength ?? 2000;
|
|
139
|
+
// The router does not match a route with a param over its limit, so the limit is checked
|
|
140
|
+
// once matched, to respond like Fastify rather than as not found
|
|
141
|
+
this._router = Router({ maxParamLength: Number.MAX_SAFE_INTEGER });
|
|
142
|
+
this._socketConfig = {
|
|
143
|
+
path: "/socket",
|
|
144
|
+
...options?.config?.socket
|
|
145
|
+
};
|
|
146
|
+
this._mimeTypeProcessorMap = {};
|
|
147
|
+
this._defaultBodyLimit = 0;
|
|
148
|
+
this._additionalServers = [];
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Returns the class name of the component.
|
|
152
|
+
* @returns The class name of the component.
|
|
153
|
+
*/
|
|
154
|
+
className() {
|
|
155
|
+
return NativeWebServer.CLASS_NAME;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Get the web server instance.
|
|
159
|
+
* @returns The web server instance.
|
|
160
|
+
*/
|
|
161
|
+
getInstance() {
|
|
162
|
+
return this._router;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Build the server.
|
|
166
|
+
* @param restRouteProcessors The processors for incoming requests over REST.
|
|
167
|
+
* @param restRoutes The REST routes.
|
|
168
|
+
* @param socketRouteProcessors The processors for incoming requests over Sockets.
|
|
169
|
+
* @param socketRoutes The socket routes.
|
|
170
|
+
* @param options Options for building the server.
|
|
171
|
+
* @returns A promise that resolves when the server is fully built and ready to start.
|
|
172
|
+
*/
|
|
173
|
+
async build(restRouteProcessors, restRoutes, socketRouteProcessors, socketRoutes, options) {
|
|
174
|
+
await this.prepareBuild(restRouteProcessors, restRoutes, socketRouteProcessors, socketRoutes, options);
|
|
175
|
+
if (Is.arrayValue(socketRoutes)) {
|
|
176
|
+
this._socketServer = new Server(this._socketConfig);
|
|
177
|
+
}
|
|
178
|
+
// Processors supplied first take precedence for a media type
|
|
179
|
+
for (const mimeTypeProcessor of [...this._mimeTypeProcessors].reverse()) {
|
|
180
|
+
for (const mediaType of mimeTypeProcessor.getTypes()) {
|
|
181
|
+
this._mimeTypeProcessorMap[mediaType.toLowerCase()] = mimeTypeProcessor;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const corsOptions = this.resolveCorsOptions(options);
|
|
185
|
+
this._cors = {
|
|
186
|
+
hasWildcardOrigin: corsOptions.hasWildcardOrigin,
|
|
187
|
+
origins: corsOptions.origins,
|
|
188
|
+
allowMethods: corsOptions.methods.join(", "),
|
|
189
|
+
allowHeaders: corsOptions.allowedHeaders.join(", "),
|
|
190
|
+
exposeHeaders: corsOptions.exposedHeaders.join(", ")
|
|
191
|
+
};
|
|
192
|
+
const bodyLimits = this.resolveBodyLimits();
|
|
193
|
+
this._defaultBodyLimit = bodyLimits[HttpBodyLimit.Default];
|
|
194
|
+
await this.addRoutesRest(bodyLimits, restRouteProcessors, restRoutes);
|
|
195
|
+
await this.addRoutesSocket(socketRouteProcessors, socketRoutes);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Start listening for connections on the transport.
|
|
199
|
+
* @param host The host to bind to.
|
|
200
|
+
* @param port The port to bind to.
|
|
201
|
+
* @returns A promise that resolves when the transport is listening.
|
|
202
|
+
*/
|
|
203
|
+
async serverListen(host, port) {
|
|
204
|
+
const listener = async (req, res) => this.handleRequest(req, res);
|
|
205
|
+
const server = Is.object(this._webConfig?.https)
|
|
206
|
+
? createHttpsServer({ ...this._webConfig.serverOptions, ...this._webConfig.https }, listener)
|
|
207
|
+
: createHttpServer(this._webConfig?.serverOptions ?? {}, listener);
|
|
208
|
+
this._socketServer?.attach(server);
|
|
209
|
+
// Generate the optimised lookups now that all the routes are registered
|
|
210
|
+
this._router.compile();
|
|
211
|
+
// Like Fastify, bind every address localhost resolves to, so both IPv4 and IPv6 clients connect
|
|
212
|
+
const hosts = host === "localhost"
|
|
213
|
+
? [...new Set((await lookup(host, { all: true })).map(a => a.address))]
|
|
214
|
+
: [host];
|
|
215
|
+
await this.listenOn(server, hosts[0], port);
|
|
216
|
+
this._server = server;
|
|
217
|
+
for (const additionalHost of hosts.slice(1)) {
|
|
218
|
+
const additionalServer = createNetServer(socket => server.emit("connection", socket));
|
|
219
|
+
try {
|
|
220
|
+
await this.listenOn(additionalServer, additionalHost, port);
|
|
221
|
+
this._additionalServers.push(additionalServer);
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
// An additional address failing to bind does not stop the server
|
|
225
|
+
await this._logging?.log({
|
|
226
|
+
level: "warn",
|
|
227
|
+
ts: Date.now(),
|
|
228
|
+
source: NativeWebServer.CLASS_NAME,
|
|
229
|
+
message: "additionalBindingFailed",
|
|
230
|
+
data: { host: additionalHost, port },
|
|
231
|
+
error: BaseError.fromError(err)
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Close the transport and all of its connections.
|
|
238
|
+
* @returns A promise that resolves when the transport has closed.
|
|
239
|
+
*/
|
|
240
|
+
async serverClose() {
|
|
241
|
+
const server = this._server;
|
|
242
|
+
const additionalServers = this._additionalServers;
|
|
243
|
+
this._server = undefined;
|
|
244
|
+
this._additionalServers = [];
|
|
245
|
+
// The forwarded connections are only released when the main server closes its idle
|
|
246
|
+
// connections, so all the servers must be closed together
|
|
247
|
+
const closing = additionalServers.map(async (s) => this.closeServer(s));
|
|
248
|
+
if (!Is.empty(this._socketServer)) {
|
|
249
|
+
closing.push(this.closeSocketServer(this._socketServer, server));
|
|
250
|
+
}
|
|
251
|
+
else if (!Is.empty(server)) {
|
|
252
|
+
closing.push(this.closeServer(server));
|
|
253
|
+
}
|
|
254
|
+
await Promise.all(closing);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Get the addresses the transport is bound to.
|
|
258
|
+
* @returns The bound addresses.
|
|
259
|
+
*/
|
|
260
|
+
serverAddresses() {
|
|
261
|
+
const addresses = [];
|
|
262
|
+
for (const server of [this._server, ...this._additionalServers]) {
|
|
263
|
+
const address = server?.address();
|
|
264
|
+
if (Is.object(address)) {
|
|
265
|
+
addresses.push({ address: address.address, family: address.family, port: address.port });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return addresses;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Whether the transport is serving over TLS.
|
|
272
|
+
* @returns True if the transport is secure.
|
|
273
|
+
*/
|
|
274
|
+
serverIsSecure() {
|
|
275
|
+
return Is.object(this._webConfig?.https);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Whether the transport is currently listening for connections.
|
|
279
|
+
* @returns True if the transport is listening.
|
|
280
|
+
*/
|
|
281
|
+
serverIsListening() {
|
|
282
|
+
return this._server?.listening ?? false;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Whether a GET route is registered for the root path.
|
|
286
|
+
* @returns True if the root route is registered.
|
|
287
|
+
*/
|
|
288
|
+
serverHasRootRoute() {
|
|
289
|
+
return this._router.hasRoute(HttpMethod.GET, "/");
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Add the REST routes to the server.
|
|
293
|
+
* @param bodyLimits The named body limits in bytes.
|
|
294
|
+
* @param restRouteProcessors The processors for the incoming requests.
|
|
295
|
+
* @param restRoutes The REST routes to add.
|
|
296
|
+
* @internal
|
|
297
|
+
*/
|
|
298
|
+
async addRoutesRest(bodyLimits, restRouteProcessors, restRoutes) {
|
|
299
|
+
if (Is.arrayValue(restRouteProcessors) && Is.arrayValue(restRoutes)) {
|
|
300
|
+
for (const restRoute of restRoutes) {
|
|
301
|
+
let path = StringHelper.trimTrailingSlashes(restRoute.path);
|
|
302
|
+
if (!path.startsWith("/")) {
|
|
303
|
+
path = `/${path}`;
|
|
304
|
+
}
|
|
305
|
+
await this._logging?.log({
|
|
306
|
+
level: "info",
|
|
307
|
+
ts: Date.now(),
|
|
308
|
+
source: NativeWebServer.CLASS_NAME,
|
|
309
|
+
message: "restRouteAdded",
|
|
310
|
+
data: { route: path, method: restRoute.method }
|
|
311
|
+
});
|
|
312
|
+
const bodyLimitKey = Is.stringValue(restRoute.bodyLimit)
|
|
313
|
+
? restRoute.bodyLimit
|
|
314
|
+
: HttpBodyLimit.Default;
|
|
315
|
+
const bodyLimit = bodyLimits[bodyLimitKey];
|
|
316
|
+
if (Is.empty(bodyLimit)) {
|
|
317
|
+
throw new GeneralError(NativeWebServer.CLASS_NAME, "unknownBodyLimit", {
|
|
318
|
+
route: path,
|
|
319
|
+
bodyLimit: bodyLimitKey
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
this._router.on(restRoute.method, path, async (req, res, params, store, searchParams) => this.handleRequestRest(req, res, bodyLimit, params, searchParams, restRoute));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Add the socket routes to the server.
|
|
328
|
+
* @param socketRouteProcessors The processors for the incoming requests.
|
|
329
|
+
* @param socketRoutes The socket routes to add.
|
|
330
|
+
* @internal
|
|
331
|
+
*/
|
|
332
|
+
async addRoutesSocket(socketRouteProcessors, socketRoutes) {
|
|
333
|
+
const io = this._socketServer;
|
|
334
|
+
if (Is.arrayValue(socketRouteProcessors) && Is.arrayValue(socketRoutes) && !Is.empty(io)) {
|
|
335
|
+
for (const socketRoute of socketRoutes) {
|
|
336
|
+
const path = StringHelper.trimLeadingSlashes(StringHelper.trimTrailingSlashes(socketRoute.path));
|
|
337
|
+
const pathParts = path.split("/");
|
|
338
|
+
const namespace = `/${pathParts[0]}`;
|
|
339
|
+
const topic = pathParts.slice(1).join("/");
|
|
340
|
+
await this._logging?.log({
|
|
341
|
+
level: "info",
|
|
342
|
+
ts: Date.now(),
|
|
343
|
+
source: NativeWebServer.CLASS_NAME,
|
|
344
|
+
message: "socketRouteAdded",
|
|
345
|
+
data: {
|
|
346
|
+
handshakePath: this._socketConfig.path,
|
|
347
|
+
namespace,
|
|
348
|
+
eventName: topic
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
const socketNamespace = io.of(namespace);
|
|
352
|
+
socketNamespace.on("connection", async (socket) => {
|
|
353
|
+
const socketServerRequest = {
|
|
354
|
+
method: HttpMethod.GET,
|
|
355
|
+
url: socket.handshake.url,
|
|
356
|
+
query: socket.handshake.query,
|
|
357
|
+
headers: socket.handshake.headers,
|
|
358
|
+
socketId: socket.id
|
|
359
|
+
};
|
|
360
|
+
// Pass the connected information on to any processors
|
|
361
|
+
try {
|
|
362
|
+
for (const connected of this._socketChains.connected) {
|
|
363
|
+
await connected(socketServerRequest, socketRoute, this._loggingComponentType);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
const { error, httpStatusCode } = HttpErrorHelper.processError(err, this._includeErrorStack);
|
|
368
|
+
const response = {};
|
|
369
|
+
HttpErrorHelper.buildResponse(response, error, httpStatusCode, this._includeErrorStack);
|
|
370
|
+
socket.emit(topic, response);
|
|
371
|
+
}
|
|
372
|
+
socket.on("disconnect", async () => {
|
|
373
|
+
try {
|
|
374
|
+
// The socket disconnected so notify any processors
|
|
375
|
+
for (const disconnected of this._socketChains.disconnected) {
|
|
376
|
+
await disconnected(socketServerRequest, socketRoute, this._loggingComponentType);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
// If something fails on a disconnect there is not much we can do with it
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
// Handle any incoming messages
|
|
384
|
+
socket.on(topic, async (data) => {
|
|
385
|
+
// The listener is async, so anything escaping it would surface as an unhandled
|
|
386
|
+
// rejection and terminate the process, emit it on the topic instead
|
|
387
|
+
try {
|
|
388
|
+
await this.handleRequestSocket(socketRoute, socket, `/${pathParts.join("/")}`, topic, data);
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
const { error, httpStatusCode } = HttpErrorHelper.processError(err, this._includeErrorStack);
|
|
392
|
+
const response = {};
|
|
393
|
+
HttpErrorHelper.buildResponse(response, error, httpStatusCode, this._includeErrorStack);
|
|
394
|
+
socket.emit(topic, response);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Handle an incoming HTTP request, routing it to its handler.
|
|
403
|
+
* @param req The incoming request.
|
|
404
|
+
* @param res The outgoing response.
|
|
405
|
+
* @internal
|
|
406
|
+
*/
|
|
407
|
+
async handleRequest(req, res) {
|
|
408
|
+
try {
|
|
409
|
+
if (this.applyCors(req, res)) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const method = (req.method ?? HttpMethod.GET);
|
|
413
|
+
const url = req.url ?? "/";
|
|
414
|
+
const queryStart = url.indexOf("?");
|
|
415
|
+
// Like Fastify, a path which is not valid percent encoding is rejected
|
|
416
|
+
const path = queryStart === -1 ? url : url.slice(0, queryStart);
|
|
417
|
+
if (path.includes("%")) {
|
|
418
|
+
try {
|
|
419
|
+
decodeURIComponent(path);
|
|
420
|
+
}
|
|
421
|
+
catch (err) {
|
|
422
|
+
throw this.createTransportError(HttpStatusCode.badRequest, new GeneralError(NativeWebServer.CLASS_NAME, "invalidUrl", { path }, err));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
let found = this._router.find(method, url);
|
|
426
|
+
// Like Fastify, HEAD requests are served by the GET route when there is no HEAD route
|
|
427
|
+
if (Is.empty(found) && method === HttpMethod.HEAD) {
|
|
428
|
+
found = this._router.find(HttpMethod.GET, url);
|
|
429
|
+
}
|
|
430
|
+
if (Is.empty(found)) {
|
|
431
|
+
const query = queryStart === -1 ? {} : parseQuery(url.slice(queryStart + 1));
|
|
432
|
+
await this.handleRequestRest(req, res, this._defaultBodyLimit, {}, query);
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
for (const param of Object.keys(found.params)) {
|
|
436
|
+
if ((found.params[param]?.length ?? 0) > this._maxParamLength) {
|
|
437
|
+
throw this.createTransportError(HttpStatusCode.uriTooLong, new GeneralError(NativeWebServer.CLASS_NAME, "paramTooLong", {
|
|
438
|
+
param,
|
|
439
|
+
maxParamLength: this._maxParamLength
|
|
440
|
+
}));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
await found.handler(req, res, found.params, found.store, found.searchParams);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
await this.sendError(res, err);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Handle the incoming REST request.
|
|
452
|
+
* @param req The incoming request.
|
|
453
|
+
* @param res The outgoing response.
|
|
454
|
+
* @param bodyLimit The maximum size of the request body in bytes.
|
|
455
|
+
* @param pathParams The request path params.
|
|
456
|
+
* @param query The request query params.
|
|
457
|
+
* @param restRoute The REST route to handle.
|
|
458
|
+
* @internal
|
|
459
|
+
*/
|
|
460
|
+
async handleRequestRest(req, res, bodyLimit, pathParams, query, restRoute) {
|
|
461
|
+
const isSecure = req.socket.encrypted === true;
|
|
462
|
+
let host = req.headers.host ?? "localhost";
|
|
463
|
+
// Like Fastify, the default port for the protocol is omitted from the origin
|
|
464
|
+
if ((isSecure && host.endsWith(":443")) || (!isSecure && host.endsWith(":80"))) {
|
|
465
|
+
host = host.slice(0, host.lastIndexOf(":"));
|
|
466
|
+
}
|
|
467
|
+
const requestOrigin = `${isSecure ? "https" : "http"}://${host}`;
|
|
468
|
+
const headers = req.headers;
|
|
469
|
+
const httpServerRequest = this.buildServerRequest((req.method ?? HttpMethod.GET).toUpperCase(), `${requestOrigin}${req.url ?? "/"}`, await this.parseBody(req, headers, bodyLimit), query, pathParams, headers);
|
|
470
|
+
const httpResponse = {};
|
|
471
|
+
const contextIds = this.buildContextIds(requestOrigin, httpServerRequest.headers);
|
|
472
|
+
const processorState = {};
|
|
473
|
+
await this.runProcessorsRest(restRoute, httpServerRequest, httpResponse, contextIds, processorState);
|
|
474
|
+
await this.sendResponse(req, res, httpResponse);
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Handle the incoming socket request.
|
|
478
|
+
* @param socketRoute The socket route to handle.
|
|
479
|
+
* @param socket The socket to handle.
|
|
480
|
+
* @param fullPath The full path of the socket route.
|
|
481
|
+
* @param emitTopic The topic to emit the response on.
|
|
482
|
+
* @param request The incoming request.
|
|
483
|
+
* @internal
|
|
484
|
+
*/
|
|
485
|
+
async handleRequestSocket(socketRoute, socket, fullPath, emitTopic, request) {
|
|
486
|
+
const socketServerRequest = {
|
|
487
|
+
method: HttpMethod.GET,
|
|
488
|
+
url: fullPath,
|
|
489
|
+
query: socket.handshake.query,
|
|
490
|
+
headers: socket.handshake.headers,
|
|
491
|
+
// The payload arrives straight off the wire, so it can be missing or any shape
|
|
492
|
+
body: Is.object(request) ? request.body : undefined,
|
|
493
|
+
socketId: socket.id
|
|
494
|
+
};
|
|
495
|
+
const httpResponse = {};
|
|
496
|
+
const contextIds = {};
|
|
497
|
+
const processorState = {};
|
|
498
|
+
delete socketServerRequest.query?.EIO;
|
|
499
|
+
delete socketServerRequest.query?.transport;
|
|
500
|
+
await this.runProcessorsSocket(socketRoute, socketServerRequest, httpResponse, contextIds, processorState, emitTopic, async (topic, response) => {
|
|
501
|
+
socket.emit(topic, response);
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Apply the CORS headers to the response, mirroring the Fastify CORS plugin.
|
|
506
|
+
* @param req The incoming request.
|
|
507
|
+
* @param res The outgoing response.
|
|
508
|
+
* @returns True if the request was a preflight request which has been responded to.
|
|
509
|
+
* @internal
|
|
510
|
+
*/
|
|
511
|
+
applyCors(req, res) {
|
|
512
|
+
const cors = this._cors;
|
|
513
|
+
if (Is.empty(cors)) {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
res.setHeader("vary", "Origin");
|
|
517
|
+
const origin = req.headers.origin;
|
|
518
|
+
const allowed = cors.hasWildcardOrigin || (Is.stringValue(origin) && cors.origins.includes(origin));
|
|
519
|
+
if (!allowed) {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
if (Is.stringValue(origin)) {
|
|
523
|
+
res.setHeader("access-control-allow-origin", origin);
|
|
524
|
+
}
|
|
525
|
+
res.setHeader("access-control-allow-credentials", "true");
|
|
526
|
+
res.setHeader("access-control-expose-headers", cors.exposeHeaders);
|
|
527
|
+
if (req.method === HttpMethod.OPTIONS) {
|
|
528
|
+
if (!Is.stringValue(origin) ||
|
|
529
|
+
!Is.stringValue(req.headers["access-control-request-method"])) {
|
|
530
|
+
res.writeHead(HttpStatusCode.badRequest, {
|
|
531
|
+
[HeaderTypes.ContentType]: `${MimeTypes.PlainText}; charset=utf-8`
|
|
532
|
+
});
|
|
533
|
+
res.end("Invalid Preflight Request");
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
res.writeHead(HttpStatusCode.noContent, {
|
|
537
|
+
"access-control-allow-methods": cors.allowMethods,
|
|
538
|
+
"access-control-allow-headers": cors.allowHeaders,
|
|
539
|
+
[HeaderTypes.ContentLength]: "0"
|
|
540
|
+
});
|
|
541
|
+
res.end();
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Read and parse the request body based on its content type.
|
|
548
|
+
* @param req The incoming request.
|
|
549
|
+
* @param headers The request headers.
|
|
550
|
+
* @param bodyLimit The maximum size of the request body in bytes.
|
|
551
|
+
* @returns The parsed body, or undefined if there is no body.
|
|
552
|
+
* @throws GeneralError If the body is too large, malformed or an unsupported media type.
|
|
553
|
+
* @internal
|
|
554
|
+
*/
|
|
555
|
+
async parseBody(req, headers, bodyLimit) {
|
|
556
|
+
if (req.method === HttpMethod.GET || req.method === HttpMethod.HEAD) {
|
|
557
|
+
return undefined;
|
|
558
|
+
}
|
|
559
|
+
const contentType = headers[HeaderTypes.ContentType];
|
|
560
|
+
const mediaType = (Is.string(contentType) ? contentType : "")
|
|
561
|
+
.split(";")[0]
|
|
562
|
+
.trim()
|
|
563
|
+
.toLowerCase();
|
|
564
|
+
const raw = await this.readBody(req, headers, bodyLimit);
|
|
565
|
+
if (Is.empty(raw)) {
|
|
566
|
+
// Like Fastify, declaring a JSON body without sending one is an error
|
|
567
|
+
if (mediaType === MimeTypes.Json) {
|
|
568
|
+
throw this.createTransportError(HttpStatusCode.badRequest, new GeneralError(NativeWebServer.CLASS_NAME, "emptyJsonBody"));
|
|
569
|
+
}
|
|
570
|
+
return undefined;
|
|
571
|
+
}
|
|
572
|
+
const mimeTypeProcessor = this._mimeTypeProcessorMap[mediaType];
|
|
573
|
+
if (!Is.empty(mimeTypeProcessor)) {
|
|
574
|
+
return mimeTypeProcessor.handle(raw);
|
|
575
|
+
}
|
|
576
|
+
if (mediaType === MimeTypes.Json) {
|
|
577
|
+
const text = Converter.bytesToUtf8(raw);
|
|
578
|
+
try {
|
|
579
|
+
return JSON.parse(text);
|
|
580
|
+
}
|
|
581
|
+
catch (err) {
|
|
582
|
+
throw this.createTransportError(HttpStatusCode.badRequest, new GeneralError(NativeWebServer.CLASS_NAME, "invalidJsonBody", undefined, err));
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (mediaType === MimeTypes.PlainText) {
|
|
586
|
+
return Converter.bytesToUtf8(raw);
|
|
587
|
+
}
|
|
588
|
+
throw this.createTransportError(HttpStatusCode.unsupportedMediaType, new GeneralError(NativeWebServer.CLASS_NAME, "unsupportedMediaType", {
|
|
589
|
+
contentType: mediaType
|
|
590
|
+
}));
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Read the raw request body, enforcing the body limit while streaming.
|
|
594
|
+
* @param req The incoming request.
|
|
595
|
+
* @param headers The request headers.
|
|
596
|
+
* @param bodyLimit The maximum size of the request body in bytes.
|
|
597
|
+
* @returns The raw body, or undefined if it is empty.
|
|
598
|
+
* @throws GeneralError If the body is larger than the limit.
|
|
599
|
+
* @internal
|
|
600
|
+
*/
|
|
601
|
+
async readBody(req, headers, bodyLimit) {
|
|
602
|
+
const contentLength = headers[HeaderTypes.ContentLength];
|
|
603
|
+
if (Is.string(contentLength) && Number.parseInt(contentLength, 10) > bodyLimit) {
|
|
604
|
+
throw this.createBodyTooLargeError(bodyLimit);
|
|
605
|
+
}
|
|
606
|
+
const chunks = [];
|
|
607
|
+
let total = 0;
|
|
608
|
+
for await (const chunk of req) {
|
|
609
|
+
const buffer = chunk;
|
|
610
|
+
total += buffer.byteLength;
|
|
611
|
+
if (total > bodyLimit) {
|
|
612
|
+
throw this.createBodyTooLargeError(bodyLimit);
|
|
613
|
+
}
|
|
614
|
+
chunks.push(buffer);
|
|
615
|
+
}
|
|
616
|
+
return total === 0 ? undefined : Buffer.concat(chunks, total);
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Create the error for a body which exceeds the limit.
|
|
620
|
+
* @param bodyLimit The maximum size of the request body in bytes.
|
|
621
|
+
* @returns The error.
|
|
622
|
+
* @internal
|
|
623
|
+
*/
|
|
624
|
+
createBodyTooLargeError(bodyLimit) {
|
|
625
|
+
return this.createTransportError(HttpStatusCode.payloadTooLarge, new GeneralError(NativeWebServer.CLASS_NAME, "bodyTooLarge", { bodyLimit }));
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Mark an error as raised by the transport, so it is responded to with a specific status code.
|
|
629
|
+
* @param statusCode The status code for the response.
|
|
630
|
+
* @param error The error.
|
|
631
|
+
* @returns The error.
|
|
632
|
+
* @internal
|
|
633
|
+
*/
|
|
634
|
+
createTransportError(statusCode, error) {
|
|
635
|
+
NativeWebServer._ERROR_STATUS_CODES.set(error, statusCode);
|
|
636
|
+
return error;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Send the processed response.
|
|
640
|
+
* @param req The incoming request.
|
|
641
|
+
* @param res The outgoing response.
|
|
642
|
+
* @param httpResponse The processed response.
|
|
643
|
+
* @internal
|
|
644
|
+
*/
|
|
645
|
+
async sendResponse(req, res, httpResponse) {
|
|
646
|
+
this.validateResponse(httpResponse);
|
|
647
|
+
const headers = {};
|
|
648
|
+
if (!Is.empty(httpResponse.headers)) {
|
|
649
|
+
for (const header of Object.keys(httpResponse.headers)) {
|
|
650
|
+
const value = httpResponse.headers[header];
|
|
651
|
+
if (!Is.empty(value)) {
|
|
652
|
+
headers[header.toLowerCase()] = value;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
const status = httpResponse.statusCode ?? HttpStatusCode.ok;
|
|
657
|
+
const payload = httpResponse.body;
|
|
658
|
+
if (Is.empty(payload) ||
|
|
659
|
+
status === HttpStatusCode.noContent ||
|
|
660
|
+
status === HttpStatusCode.notModified) {
|
|
661
|
+
res.writeHead(status, headers);
|
|
662
|
+
res.end();
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const stream = this.toNodeStream(payload);
|
|
666
|
+
if (!Is.empty(stream)) {
|
|
667
|
+
headers[HeaderTypes.ContentType] ??= MimeTypes.OctetStream;
|
|
668
|
+
const encoding = this.selectEncoding(req, headers);
|
|
669
|
+
if (Is.stringValue(encoding)) {
|
|
670
|
+
delete headers[HeaderTypes.ContentLength];
|
|
671
|
+
headers["content-encoding"] = encoding;
|
|
672
|
+
this.appendVary(res, headers, "Accept-Encoding");
|
|
673
|
+
res.writeHead(status, headers);
|
|
674
|
+
await pipeline(stream, NativeWebServer._STREAM_COMPRESSORS[encoding](), res);
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
res.writeHead(status, headers);
|
|
678
|
+
await pipeline(stream, res);
|
|
679
|
+
}
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
let body;
|
|
683
|
+
if (Is.uint8Array(payload)) {
|
|
684
|
+
body = payload;
|
|
685
|
+
headers[HeaderTypes.ContentType] ??= MimeTypes.OctetStream;
|
|
686
|
+
}
|
|
687
|
+
else if (Is.string(payload)) {
|
|
688
|
+
body = Converter.utf8ToBytes(payload);
|
|
689
|
+
headers[HeaderTypes.ContentType] ??= `${MimeTypes.PlainText}; charset=utf-8`;
|
|
690
|
+
}
|
|
691
|
+
else {
|
|
692
|
+
body = Converter.utf8ToBytes(JSON.stringify(payload));
|
|
693
|
+
headers[HeaderTypes.ContentType] ??= `${MimeTypes.Json}; charset=utf-8`;
|
|
694
|
+
}
|
|
695
|
+
if (body.byteLength >= NativeWebServer._COMPRESS_THRESHOLD) {
|
|
696
|
+
const encoding = this.selectEncoding(req, headers);
|
|
697
|
+
if (Is.stringValue(encoding)) {
|
|
698
|
+
body = await NativeWebServer._COMPRESSORS[encoding](body);
|
|
699
|
+
headers["content-encoding"] = encoding;
|
|
700
|
+
this.appendVary(res, headers, "Accept-Encoding");
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
headers[HeaderTypes.ContentLength] = body.byteLength;
|
|
704
|
+
res.writeHead(status, headers);
|
|
705
|
+
res.end(body);
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Convert a streaming response payload to a Node stream.
|
|
709
|
+
* @param payload The response payload.
|
|
710
|
+
* @returns The stream, or undefined if the payload is not a stream.
|
|
711
|
+
* @internal
|
|
712
|
+
*/
|
|
713
|
+
toNodeStream(payload) {
|
|
714
|
+
if (Is.function(payload.pipe)) {
|
|
715
|
+
return payload;
|
|
716
|
+
}
|
|
717
|
+
if (Is.function(payload.getReader)) {
|
|
718
|
+
return Readable.fromWeb(payload);
|
|
719
|
+
}
|
|
720
|
+
return undefined;
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Select the encoding for the response, if it should be compressed.
|
|
724
|
+
* @param req The incoming request.
|
|
725
|
+
* @param headers The response headers.
|
|
726
|
+
* @returns The encoding, or undefined if the response should not be compressed.
|
|
727
|
+
* @internal
|
|
728
|
+
*/
|
|
729
|
+
selectEncoding(req, headers) {
|
|
730
|
+
const acceptEncoding = req.headers["accept-encoding"];
|
|
731
|
+
const contentType = headers[HeaderTypes.ContentType];
|
|
732
|
+
if (!Is.stringValue(acceptEncoding) ||
|
|
733
|
+
!Is.empty(headers["content-encoding"]) ||
|
|
734
|
+
!Is.string(contentType) ||
|
|
735
|
+
!NativeWebServer._COMPRESSIBLE_TYPES.test(contentType)) {
|
|
736
|
+
return undefined;
|
|
737
|
+
}
|
|
738
|
+
let selected;
|
|
739
|
+
let selectedQuality = 0;
|
|
740
|
+
for (const part of acceptEncoding.toLowerCase().split(",")) {
|
|
741
|
+
const [name, ...params] = part.trim().split(";");
|
|
742
|
+
const qualityParam = params.find(p => p.trim().startsWith("q="));
|
|
743
|
+
const quality = Is.stringValue(qualityParam)
|
|
744
|
+
? Number.parseFloat(qualityParam.trim().slice(2))
|
|
745
|
+
: 1;
|
|
746
|
+
const encoding = name === "x-gzip" ? "gzip" : name;
|
|
747
|
+
const candidates = encoding === "*"
|
|
748
|
+
? NativeWebServer._ENCODINGS
|
|
749
|
+
: NativeWebServer._ENCODINGS.filter(e => e === encoding);
|
|
750
|
+
for (const candidate of candidates) {
|
|
751
|
+
const isBetter = quality > selectedQuality ||
|
|
752
|
+
(quality === selectedQuality &&
|
|
753
|
+
Is.stringValue(selected) &&
|
|
754
|
+
NativeWebServer._ENCODINGS.indexOf(candidate) <
|
|
755
|
+
NativeWebServer._ENCODINGS.indexOf(selected));
|
|
756
|
+
if (quality > 0 && isBetter) {
|
|
757
|
+
selected = candidate;
|
|
758
|
+
selectedQuality = quality;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return selected;
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Append a value to the vary header, keeping any value already set on the response.
|
|
766
|
+
* @param res The outgoing response.
|
|
767
|
+
* @param headers The response headers.
|
|
768
|
+
* @param value The value to append.
|
|
769
|
+
* @internal
|
|
770
|
+
*/
|
|
771
|
+
appendVary(res, headers, value) {
|
|
772
|
+
const existing = headers.vary ?? res.getHeader("vary");
|
|
773
|
+
if (Is.empty(existing)) {
|
|
774
|
+
headers.vary = value;
|
|
775
|
+
}
|
|
776
|
+
else {
|
|
777
|
+
headers.vary = `${Is.array(existing) ? existing.join(", ") : String(existing)}, ${value}`;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Send an error response.
|
|
782
|
+
* @param res The outgoing response.
|
|
783
|
+
* @param error The error to send.
|
|
784
|
+
* @internal
|
|
785
|
+
*/
|
|
786
|
+
async sendError(res, error) {
|
|
787
|
+
let httpStatusCode = NativeWebServer._ERROR_STATUS_CODES.get(error);
|
|
788
|
+
let err;
|
|
789
|
+
if (Is.empty(httpStatusCode)) {
|
|
790
|
+
const errorAndCode = HttpErrorHelper.processError(error);
|
|
791
|
+
err = errorAndCode.error;
|
|
792
|
+
httpStatusCode = errorAndCode.httpStatusCode;
|
|
793
|
+
}
|
|
794
|
+
else {
|
|
795
|
+
err = BaseError.fromError(error).toJsonObject();
|
|
796
|
+
}
|
|
797
|
+
await this._logging?.log({
|
|
798
|
+
level: "error",
|
|
799
|
+
ts: Date.now(),
|
|
800
|
+
source: NativeWebServer.CLASS_NAME,
|
|
801
|
+
message: "badRequest",
|
|
802
|
+
error: err
|
|
803
|
+
});
|
|
804
|
+
if (res.headersSent) {
|
|
805
|
+
// The response is already partly sent, so the only option left is to drop it
|
|
806
|
+
res.destroy();
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const body = Converter.utf8ToBytes(JSON.stringify({ error: err }));
|
|
810
|
+
const headers = {
|
|
811
|
+
[HeaderTypes.ContentType]: `${MimeTypes.Json}; charset=utf-8`,
|
|
812
|
+
[HeaderTypes.ContentLength]: body.byteLength
|
|
813
|
+
};
|
|
814
|
+
if (httpStatusCode === HttpStatusCode.payloadTooLarge) {
|
|
815
|
+
// The unread body would otherwise hold the connection open, so close it like Fastify
|
|
816
|
+
headers.connection = "close";
|
|
817
|
+
}
|
|
818
|
+
res.writeHead(httpStatusCode, headers);
|
|
819
|
+
res.end(body);
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Listen on a host and port.
|
|
823
|
+
* @param server The server to listen with.
|
|
824
|
+
* @param host The host to bind to.
|
|
825
|
+
* @param port The port to bind to.
|
|
826
|
+
* @returns A promise that resolves when the server is listening.
|
|
827
|
+
* @internal
|
|
828
|
+
*/
|
|
829
|
+
async listenOn(server, host, port) {
|
|
830
|
+
await new Promise((resolve, reject) => {
|
|
831
|
+
server.once("error", reject);
|
|
832
|
+
server.listen(port, host, () => {
|
|
833
|
+
server.off("error", reject);
|
|
834
|
+
resolve();
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Close the socket server, which also closes the server it is attached to.
|
|
840
|
+
* @param socketServer The socket server to close.
|
|
841
|
+
* @param server The server the socket server is attached to.
|
|
842
|
+
* @returns A promise that resolves when both servers have closed.
|
|
843
|
+
* @internal
|
|
844
|
+
*/
|
|
845
|
+
async closeSocketServer(socketServer, server) {
|
|
846
|
+
const serverClosed = Is.empty(server) ? undefined : once(server, "close");
|
|
847
|
+
socketServer.disconnectSockets();
|
|
848
|
+
await socketServer.close();
|
|
849
|
+
await serverClosed;
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Close a server.
|
|
853
|
+
* @param server The server to close.
|
|
854
|
+
* @returns A promise that resolves when the server has closed.
|
|
855
|
+
* @internal
|
|
856
|
+
*/
|
|
857
|
+
async closeServer(server) {
|
|
858
|
+
await new Promise((resolve, reject) => {
|
|
859
|
+
server.close(err => (Is.empty(err) ? resolve() : reject(err)));
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
//# sourceMappingURL=nativeWebServer.js.map
|