@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.
@@ -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