@3sixty/api-server-hono 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,759 @@
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 { createSecureServer as createSecureHttp2Server } from "node:http2";
6
+ import { createServer as createHttpsServer } from "node:https";
7
+ import { createServer as createNetServer } from "node:net";
8
+ import { BaseServer } from "@3sixty/api-core-server";
9
+ import { HttpBodyLimit, HttpErrorHelper } from "@3sixty/api-models";
10
+ import { BaseError, Converter, GeneralError, Is, StringHelper } from "@3sixty/core";
11
+ import { HeaderTypes, HttpMethod, HttpStatusCode, MimeTypes } from "@3sixty/web";
12
+ import { createAdaptorServer } from "@hono/node-server";
13
+ import { Hono } from "hono";
14
+ import { compress } from "hono/compress";
15
+ import { HTTPException } from "hono/http-exception";
16
+ import { Server } from "socket.io";
17
+ /**
18
+ * Implementation of the web server using Hono.
19
+ */
20
+ export class HonoWebServer extends BaseServer {
21
+ /**
22
+ * Runtime name for the class.
23
+ */
24
+ static CLASS_NAME = "HonoWebServer";
25
+ /**
26
+ * The maximum length of a path param, matching the Fastify server.
27
+ * @internal
28
+ */
29
+ static _MAX_PARAM_LENGTH = 2000;
30
+ /**
31
+ * The Hono application.
32
+ * @internal
33
+ */
34
+ _app;
35
+ /**
36
+ * The options for the node web server.
37
+ * @internal
38
+ */
39
+ _webConfig;
40
+ /**
41
+ * The options for the socket server.
42
+ * @internal
43
+ */
44
+ _socketConfig;
45
+ /**
46
+ * The mime type processors keyed by the media type they handle.
47
+ * @internal
48
+ */
49
+ _mimeTypeProcessorMap;
50
+ /**
51
+ * The node server the application is served on, available once listening.
52
+ * @internal
53
+ */
54
+ _server;
55
+ /**
56
+ * Listeners for additional bound addresses, which forward connections to the main server.
57
+ * @internal
58
+ */
59
+ _additionalServers;
60
+ /**
61
+ * The socket server, available once built with socket routes.
62
+ * @internal
63
+ */
64
+ _socketServer;
65
+ /**
66
+ * Create a new instance of HonoWebServer.
67
+ * @param options The options for the server.
68
+ */
69
+ constructor(options) {
70
+ super({
71
+ loggingComponentType: options?.loggingComponentType,
72
+ mimeTypeProcessors: options?.mimeTypeProcessors,
73
+ includeErrorStack: options?.config?.includeErrorStack
74
+ });
75
+ this._app = new Hono();
76
+ this._webConfig = options?.config?.web;
77
+ this._socketConfig = {
78
+ path: "/socket",
79
+ ...options?.config?.socket
80
+ };
81
+ this._mimeTypeProcessorMap = {};
82
+ this._additionalServers = [];
83
+ }
84
+ /**
85
+ * Compare two route paths, ordering a static segment before a param in the same position.
86
+ * @param a The first route path.
87
+ * @param b The second route path.
88
+ * @returns Negative if a should be registered first, positive if b should, otherwise zero.
89
+ * @internal
90
+ */
91
+ static compareRouteSpecificity(a, b) {
92
+ const aSegments = StringHelper.trimLeadingSlashes(a).split("/");
93
+ const bSegments = StringHelper.trimLeadingSlashes(b).split("/");
94
+ const length = Math.min(aSegments.length, bSegments.length);
95
+ for (let i = 0; i < length; i++) {
96
+ const aIsParam = aSegments[i].startsWith(":");
97
+ const bIsParam = bSegments[i].startsWith(":");
98
+ if (aIsParam !== bIsParam) {
99
+ return aIsParam ? 1 : -1;
100
+ }
101
+ }
102
+ return 0;
103
+ }
104
+ /**
105
+ * Returns the class name of the component.
106
+ * @returns The class name of the component.
107
+ */
108
+ className() {
109
+ return HonoWebServer.CLASS_NAME;
110
+ }
111
+ /**
112
+ * Get the web server instance.
113
+ * @returns The web server instance.
114
+ */
115
+ getInstance() {
116
+ return this._app;
117
+ }
118
+ /**
119
+ * Build the server.
120
+ * @param restRouteProcessors The processors for incoming requests over REST.
121
+ * @param restRoutes The REST routes.
122
+ * @param socketRouteProcessors The processors for incoming requests over Sockets.
123
+ * @param socketRoutes The socket routes.
124
+ * @param options Options for building the server.
125
+ * @returns A promise that resolves when the server is fully built and ready to start.
126
+ */
127
+ async build(restRouteProcessors, restRoutes, socketRouteProcessors, socketRoutes, options) {
128
+ await this.prepareBuild(restRouteProcessors, restRoutes, socketRouteProcessors, socketRoutes, options);
129
+ // Only run the compression middleware when the client accepts an encoding, as it
130
+ // reads the request and response headers through the slower fetch API
131
+ const compressMiddleware = compress();
132
+ this._app.use(async (c, next) => {
133
+ if (Is.stringValue(this.getRequestHeaders(c)["accept-encoding"])) {
134
+ await compressMiddleware(c, next);
135
+ }
136
+ else {
137
+ await next();
138
+ }
139
+ });
140
+ if (Is.arrayValue(socketRoutes)) {
141
+ this._socketServer = new Server(this._socketConfig);
142
+ }
143
+ // Processors supplied first take precedence for a media type
144
+ for (const mimeTypeProcessor of [...this._mimeTypeProcessors].reverse()) {
145
+ for (const mediaType of mimeTypeProcessor.getTypes()) {
146
+ this._mimeTypeProcessorMap[mediaType.toLowerCase()] = mimeTypeProcessor;
147
+ }
148
+ }
149
+ this.initCors(options);
150
+ const bodyLimits = this.resolveBodyLimits();
151
+ this._app.notFound(async (c) => this.handleRequestRest(c, bodyLimits[HttpBodyLimit.Default]));
152
+ this._app.onError(async (error, c) => {
153
+ // HTTP exceptions are raised by the transport, everything else is from our framework
154
+ let httpStatusCode;
155
+ let err;
156
+ const httpException = error;
157
+ if (Is.integer(httpException.status) && Is.function(httpException.getResponse)) {
158
+ err = BaseError.fromError(httpException.cause).toJsonObject();
159
+ httpStatusCode = httpException.status;
160
+ }
161
+ else {
162
+ const errorAndCode = HttpErrorHelper.processError(error);
163
+ err = errorAndCode.error;
164
+ httpStatusCode = errorAndCode.httpStatusCode;
165
+ }
166
+ await this._logging?.log({
167
+ level: "error",
168
+ ts: Date.now(),
169
+ source: HonoWebServer.CLASS_NAME,
170
+ message: "badRequest",
171
+ error: err
172
+ });
173
+ if (httpStatusCode === HttpStatusCode.payloadTooLarge) {
174
+ // The unread body would otherwise hold the connection open, so close it like Fastify
175
+ c.header("connection", "close");
176
+ }
177
+ return c.json({ error: err }, httpStatusCode);
178
+ });
179
+ await this.addRoutesRest(bodyLimits, restRouteProcessors, restRoutes);
180
+ await this.addRoutesSocket(socketRouteProcessors, socketRoutes);
181
+ }
182
+ /**
183
+ * Start listening for connections on the transport.
184
+ * @param host The host to bind to.
185
+ * @param port The port to bind to.
186
+ * @returns A promise that resolves when the transport is listening.
187
+ */
188
+ async serverListen(host, port) {
189
+ // The config omits the options the server controls, which flattens the server type union
190
+ const server = createAdaptorServer({
191
+ ...this._webConfig,
192
+ fetch: this._app.fetch
193
+ });
194
+ this._socketServer?.attach(server);
195
+ // Like Fastify, bind every address localhost resolves to, so both IPv4 and IPv6 clients connect
196
+ const hosts = host === "localhost"
197
+ ? [...new Set((await lookup(host, { all: true })).map(a => a.address))]
198
+ : [host];
199
+ await this.listenOn(server, hosts[0], port);
200
+ this._server = server;
201
+ for (const additionalHost of hosts.slice(1)) {
202
+ const additionalServer = createNetServer(socket => server.emit("connection", socket));
203
+ try {
204
+ await this.listenOn(additionalServer, additionalHost, port);
205
+ this._additionalServers.push(additionalServer);
206
+ }
207
+ catch (err) {
208
+ // Like Fastify, an additional address failing to bind does not stop the server
209
+ await this._logging?.log({
210
+ level: "warn",
211
+ ts: Date.now(),
212
+ source: HonoWebServer.CLASS_NAME,
213
+ message: "additionalBindingFailed",
214
+ data: { host: additionalHost, port },
215
+ error: BaseError.fromError(err)
216
+ });
217
+ }
218
+ }
219
+ }
220
+ /**
221
+ * Close the transport and all of its connections.
222
+ * @returns A promise that resolves when the transport has closed.
223
+ */
224
+ async serverClose() {
225
+ const server = this._server;
226
+ const additionalServers = this._additionalServers;
227
+ this._server = undefined;
228
+ this._additionalServers = [];
229
+ // The forwarded connections are only released when the main server closes its idle
230
+ // connections, so all the servers must be closed together
231
+ const closing = additionalServers.map(async (s) => this.closeServer(s));
232
+ if (!Is.empty(this._socketServer)) {
233
+ closing.push(this.closeSocketServer(this._socketServer, server));
234
+ }
235
+ else if (!Is.empty(server)) {
236
+ closing.push(this.closeServer(server));
237
+ }
238
+ await Promise.all(closing);
239
+ }
240
+ /**
241
+ * Get the addresses the transport is bound to.
242
+ * @returns The bound addresses.
243
+ */
244
+ serverAddresses() {
245
+ const addresses = [];
246
+ for (const server of [this._server, ...this._additionalServers]) {
247
+ const address = server?.address();
248
+ if (Is.object(address)) {
249
+ addresses.push({ address: address.address, family: address.family, port: address.port });
250
+ }
251
+ }
252
+ return addresses;
253
+ }
254
+ /**
255
+ * Whether the transport is serving over TLS.
256
+ * @returns True if the transport is secure.
257
+ */
258
+ serverIsSecure() {
259
+ if (!Is.empty(this._server)) {
260
+ return Is.function(this._server.setSecureContext);
261
+ }
262
+ // Before listening the server factory determines whether it will be secure
263
+ const createServer = this._webConfig?.createServer;
264
+ return createServer === createHttpsServer || createServer === createSecureHttp2Server;
265
+ }
266
+ /**
267
+ * Whether the transport is currently listening for connections.
268
+ * @returns True if the transport is listening.
269
+ */
270
+ serverIsListening() {
271
+ return this._server?.listening ?? false;
272
+ }
273
+ /**
274
+ * Whether a GET route is registered for the root path.
275
+ * @returns True if the root route is registered.
276
+ */
277
+ serverHasRootRoute() {
278
+ return this._app.routes.some(route => route.method === HttpMethod.GET && route.path === "/");
279
+ }
280
+ /**
281
+ * Add the REST routes to the server.
282
+ * @param bodyLimits The named body limits in bytes.
283
+ * @param restRouteProcessors The processors for the incoming requests.
284
+ * @param restRoutes The REST routes to add.
285
+ * @internal
286
+ */
287
+ async addRoutesRest(bodyLimits, restRouteProcessors, restRoutes) {
288
+ if (Is.arrayValue(restRouteProcessors) && Is.arrayValue(restRoutes)) {
289
+ // Hono matches overlapping routes in registration order, so register the routes with a
290
+ // static segment before those with a param in the same position, as find-my-way does
291
+ const sortedRoutes = [...restRoutes].sort((a, b) => HonoWebServer.compareRouteSpecificity(a.path, b.path));
292
+ for (const restRoute of sortedRoutes) {
293
+ let path = StringHelper.trimTrailingSlashes(restRoute.path);
294
+ if (!path.startsWith("/")) {
295
+ path = `/${path}`;
296
+ }
297
+ await this._logging?.log({
298
+ level: "info",
299
+ ts: Date.now(),
300
+ source: HonoWebServer.CLASS_NAME,
301
+ message: "restRouteAdded",
302
+ data: { route: path, method: restRoute.method }
303
+ });
304
+ const bodyLimitKey = Is.stringValue(restRoute.bodyLimit)
305
+ ? restRoute.bodyLimit
306
+ : HttpBodyLimit.Default;
307
+ const bodyLimit = bodyLimits[bodyLimitKey];
308
+ if (Is.empty(bodyLimit)) {
309
+ throw new GeneralError(HonoWebServer.CLASS_NAME, "unknownBodyLimit", {
310
+ route: path,
311
+ bodyLimit: bodyLimitKey
312
+ });
313
+ }
314
+ // Like find-my-way, a param can match an empty segment
315
+ const honoPath = path.replace(/:(\w+)/g, ":$1{[^/]*}");
316
+ this._app.on(restRoute.method, honoPath, async (c) => this.handleRequestRest(c, bodyLimit, restRoute));
317
+ }
318
+ }
319
+ }
320
+ /**
321
+ * Add the socket routes to the server.
322
+ * @param socketRouteProcessors The processors for the incoming requests.
323
+ * @param socketRoutes The socket routes to add.
324
+ * @internal
325
+ */
326
+ async addRoutesSocket(socketRouteProcessors, socketRoutes) {
327
+ const io = this._socketServer;
328
+ if (Is.arrayValue(socketRouteProcessors) && Is.arrayValue(socketRoutes) && !Is.empty(io)) {
329
+ for (const socketRoute of socketRoutes) {
330
+ const path = StringHelper.trimLeadingSlashes(StringHelper.trimTrailingSlashes(socketRoute.path));
331
+ const pathParts = path.split("/");
332
+ const namespace = `/${pathParts[0]}`;
333
+ const topic = pathParts.slice(1).join("/");
334
+ await this._logging?.log({
335
+ level: "info",
336
+ ts: Date.now(),
337
+ source: HonoWebServer.CLASS_NAME,
338
+ message: "socketRouteAdded",
339
+ data: {
340
+ handshakePath: this._socketConfig.path,
341
+ namespace,
342
+ eventName: topic
343
+ }
344
+ });
345
+ const socketNamespace = io.of(namespace);
346
+ socketNamespace.on("connection", async (socket) => {
347
+ const socketServerRequest = {
348
+ method: HttpMethod.GET,
349
+ url: socket.handshake.url,
350
+ query: socket.handshake.query,
351
+ headers: socket.handshake.headers,
352
+ socketId: socket.id
353
+ };
354
+ // Pass the connected information on to any processors
355
+ try {
356
+ for (const connected of this._socketChains.connected) {
357
+ await connected(socketServerRequest, socketRoute, this._loggingComponentType);
358
+ }
359
+ }
360
+ catch (err) {
361
+ const { error, httpStatusCode } = HttpErrorHelper.processError(err, this._includeErrorStack);
362
+ const response = {};
363
+ HttpErrorHelper.buildResponse(response, error, httpStatusCode, this._includeErrorStack);
364
+ socket.emit(topic, response);
365
+ }
366
+ socket.on("disconnect", async () => {
367
+ try {
368
+ // The socket disconnected so notify any processors
369
+ for (const disconnected of this._socketChains.disconnected) {
370
+ await disconnected(socketServerRequest, socketRoute, this._loggingComponentType);
371
+ }
372
+ }
373
+ catch {
374
+ // If something fails on a disconnect there is not much we can do with it
375
+ }
376
+ });
377
+ // Handle any incoming messages
378
+ socket.on(topic, async (data) => {
379
+ // The listener is async, so anything escaping it would surface as an unhandled
380
+ // rejection and terminate the process, emit it on the topic instead
381
+ try {
382
+ await this.handleRequestSocket(socketRoute, socket, `/${pathParts.join("/")}`, topic, data);
383
+ }
384
+ catch (err) {
385
+ const { error, httpStatusCode } = HttpErrorHelper.processError(err, this._includeErrorStack);
386
+ const response = {};
387
+ HttpErrorHelper.buildResponse(response, error, httpStatusCode, this._includeErrorStack);
388
+ socket.emit(topic, response);
389
+ }
390
+ });
391
+ });
392
+ }
393
+ }
394
+ }
395
+ /**
396
+ * Handle the incoming REST request.
397
+ * @param c The Hono context for the request.
398
+ * @param bodyLimit The maximum size of the request body in bytes.
399
+ * @param restRoute The REST route to handle.
400
+ * @returns The response.
401
+ * @internal
402
+ */
403
+ async handleRequestRest(c, bodyLimit, restRoute) {
404
+ const url = c.req.url;
405
+ const pathStart = url.indexOf("/", url.indexOf("//") + 2);
406
+ const requestOrigin = pathStart === -1 ? url : url.slice(0, pathStart);
407
+ const headers = this.getRequestHeaders(c);
408
+ // Like Fastify, a path which is not valid percent encoding is rejected, the raw path is
409
+ // checked as Hono may already have partially decoded its own copy
410
+ const queryStart = url.indexOf("?", pathStart);
411
+ const path = pathStart === -1 ? "/" : url.slice(pathStart, queryStart === -1 ? undefined : queryStart);
412
+ if (path.includes("%")) {
413
+ try {
414
+ decodeURIComponent(path);
415
+ }
416
+ catch (err) {
417
+ throw new HTTPException(HttpStatusCode.badRequest, {
418
+ cause: new GeneralError(HonoWebServer.CLASS_NAME, "invalidUrl", { path }, err)
419
+ });
420
+ }
421
+ }
422
+ const pathParams = c.req.param();
423
+ for (const param of Object.keys(pathParams)) {
424
+ if (pathParams[param].length > HonoWebServer._MAX_PARAM_LENGTH) {
425
+ throw new HTTPException(HttpStatusCode.uriTooLong, {
426
+ cause: new GeneralError(HonoWebServer.CLASS_NAME, "paramTooLong", {
427
+ param,
428
+ maxParamLength: HonoWebServer._MAX_PARAM_LENGTH
429
+ })
430
+ });
431
+ }
432
+ }
433
+ const httpServerRequest = this.buildServerRequest(c.req.method.toUpperCase(), url, await this.parseBody(c, headers, bodyLimit), this.getRequestQuery(c), pathParams, headers);
434
+ const httpResponse = {};
435
+ const contextIds = this.buildContextIds(requestOrigin, httpServerRequest.headers);
436
+ const processorState = {};
437
+ await this.runProcessorsRest(restRoute, httpServerRequest, httpResponse, contextIds, processorState);
438
+ this.validateResponse(httpResponse);
439
+ return this.buildResponse(httpResponse);
440
+ }
441
+ /**
442
+ * Handle the incoming socket request.
443
+ * @param socketRoute The socket route to handle.
444
+ * @param socket The socket to handle.
445
+ * @param fullPath The full path of the socket route.
446
+ * @param emitTopic The topic to emit the response on.
447
+ * @param request The incoming request.
448
+ * @internal
449
+ */
450
+ async handleRequestSocket(socketRoute, socket, fullPath, emitTopic, request) {
451
+ const socketServerRequest = {
452
+ method: HttpMethod.GET,
453
+ url: fullPath,
454
+ query: socket.handshake.query,
455
+ headers: socket.handshake.headers,
456
+ // The payload arrives straight off the wire, so it can be missing or any shape
457
+ body: Is.object(request) ? request.body : undefined,
458
+ socketId: socket.id
459
+ };
460
+ const httpResponse = {};
461
+ const contextIds = {};
462
+ const processorState = {};
463
+ delete socketServerRequest.query?.EIO;
464
+ delete socketServerRequest.query?.transport;
465
+ await this.runProcessorsSocket(socketRoute, socketServerRequest, httpResponse, contextIds, processorState, emitTopic, async (topic, response) => {
466
+ socket.emit(topic, response);
467
+ });
468
+ }
469
+ /**
470
+ * Get the request query, like Fastify a repeated key gives an array of its values.
471
+ * @param c The Hono context for the request.
472
+ * @returns The request query.
473
+ * @internal
474
+ */
475
+ getRequestQuery(c) {
476
+ const query = {};
477
+ const queries = c.req.queries();
478
+ for (const key of Object.keys(queries)) {
479
+ const values = queries[key];
480
+ query[key] = values.length === 1 ? values[0] : values;
481
+ }
482
+ return query;
483
+ }
484
+ /**
485
+ * Get the request headers, using the headers already parsed by Node when available,
486
+ * as building them from the fetch request is comparatively expensive.
487
+ * @param c The Hono context for the request.
488
+ * @returns The request headers with lower case names.
489
+ * @internal
490
+ */
491
+ getRequestHeaders(c) {
492
+ const incoming = c.env?.incoming;
493
+ return (incoming?.headers ?? c.req.header());
494
+ }
495
+ /**
496
+ * Read and parse the request body based on its content type.
497
+ * @param c The Hono context for the request.
498
+ * @param headers The request headers.
499
+ * @param bodyLimit The maximum size of the request body in bytes.
500
+ * @returns The parsed body, or undefined if there is no body.
501
+ * @throws HTTPException If the body is too large, malformed or an unsupported media type.
502
+ * @internal
503
+ */
504
+ async parseBody(c, headers, bodyLimit) {
505
+ const method = c.req.method;
506
+ if (method === HttpMethod.GET || method === HttpMethod.HEAD) {
507
+ return undefined;
508
+ }
509
+ const contentType = headers[HeaderTypes.ContentType];
510
+ const mediaType = (Is.string(contentType) ? contentType : "")
511
+ .split(";")[0]
512
+ .trim()
513
+ .toLowerCase();
514
+ const raw = await this.readBody(c, headers, bodyLimit);
515
+ if (Is.empty(raw)) {
516
+ // Like Fastify, declaring a JSON body without sending one is an error
517
+ if (mediaType === MimeTypes.Json) {
518
+ throw new HTTPException(HttpStatusCode.badRequest, {
519
+ cause: new GeneralError(HonoWebServer.CLASS_NAME, "emptyJsonBody")
520
+ });
521
+ }
522
+ return undefined;
523
+ }
524
+ const mimeTypeProcessor = this._mimeTypeProcessorMap[mediaType];
525
+ if (!Is.empty(mimeTypeProcessor)) {
526
+ return mimeTypeProcessor.handle(raw);
527
+ }
528
+ if (mediaType === MimeTypes.Json) {
529
+ const text = Converter.bytesToUtf8(raw);
530
+ try {
531
+ return JSON.parse(text);
532
+ }
533
+ catch (err) {
534
+ throw new HTTPException(HttpStatusCode.badRequest, {
535
+ cause: new GeneralError(HonoWebServer.CLASS_NAME, "invalidJsonBody", undefined, err)
536
+ });
537
+ }
538
+ }
539
+ if (mediaType === MimeTypes.PlainText) {
540
+ return Converter.bytesToUtf8(raw);
541
+ }
542
+ throw new HTTPException(HttpStatusCode.unsupportedMediaType, {
543
+ cause: new GeneralError(HonoWebServer.CLASS_NAME, "unsupportedMediaType", {
544
+ contentType: mediaType
545
+ })
546
+ });
547
+ }
548
+ /**
549
+ * Read the raw request body, enforcing the body limit while streaming.
550
+ * @param c The Hono context for the request.
551
+ * @param headers The request headers.
552
+ * @param bodyLimit The maximum size of the request body in bytes.
553
+ * @returns The raw body, or undefined if it is empty.
554
+ * @throws HTTPException If the body is larger than the limit.
555
+ * @internal
556
+ */
557
+ async readBody(c, headers, bodyLimit) {
558
+ const contentLength = headers[HeaderTypes.ContentLength];
559
+ if (Is.string(contentLength) && Number.parseInt(contentLength, 10) > bodyLimit) {
560
+ throw this.createBodyTooLargeError(bodyLimit);
561
+ }
562
+ // Reading the Node stream directly avoids wrapping it in a web stream
563
+ const source = c.env?.incoming ?? c.req.raw.body;
564
+ if (Is.empty(source)) {
565
+ return undefined;
566
+ }
567
+ const chunks = [];
568
+ let total = 0;
569
+ for await (const chunk of source) {
570
+ total += chunk.byteLength;
571
+ if (total > bodyLimit) {
572
+ throw this.createBodyTooLargeError(bodyLimit);
573
+ }
574
+ chunks.push(chunk);
575
+ }
576
+ return total === 0 ? undefined : Buffer.concat(chunks, total);
577
+ }
578
+ /**
579
+ * Create the error for a body which exceeds the limit.
580
+ * @param bodyLimit The maximum size of the request body in bytes.
581
+ * @returns The error.
582
+ * @internal
583
+ */
584
+ createBodyTooLargeError(bodyLimit) {
585
+ return new HTTPException(HttpStatusCode.payloadTooLarge, {
586
+ cause: new GeneralError(HonoWebServer.CLASS_NAME, "bodyTooLarge", { bodyLimit })
587
+ });
588
+ }
589
+ /**
590
+ * Build the transport response from the processed response.
591
+ * @param httpResponse The processed response.
592
+ * @returns The response.
593
+ * @internal
594
+ */
595
+ buildResponse(httpResponse) {
596
+ // A plain header record keeps the response on the node adapter's fast path,
597
+ // multiple values for a header can only be represented with a Headers instance
598
+ const headers = {};
599
+ let multiValueHeaders;
600
+ if (!Is.empty(httpResponse.headers)) {
601
+ for (const header of Object.keys(httpResponse.headers)) {
602
+ const value = httpResponse.headers[header];
603
+ if (Is.array(value)) {
604
+ multiValueHeaders ??= new Headers();
605
+ for (const item of value) {
606
+ multiValueHeaders.append(header, item);
607
+ }
608
+ }
609
+ else if (!Is.empty(value)) {
610
+ headers[header.toLowerCase()] = String(value);
611
+ }
612
+ }
613
+ }
614
+ const status = httpResponse.statusCode ?? HttpStatusCode.ok;
615
+ const payload = httpResponse.body;
616
+ if (Is.empty(payload) ||
617
+ status === HttpStatusCode.noContent ||
618
+ status === HttpStatusCode.notModified) {
619
+ return new Response(null, {
620
+ status,
621
+ headers: this.mergeHeaders(headers, multiValueHeaders)
622
+ });
623
+ }
624
+ if (Is.function(payload.getReader)) {
625
+ headers[HeaderTypes.ContentType] ??= MimeTypes.OctetStream;
626
+ return new Response(payload, {
627
+ status,
628
+ headers: this.mergeHeaders(headers, multiValueHeaders)
629
+ });
630
+ }
631
+ let body;
632
+ if (Is.uint8Array(payload)) {
633
+ body = payload;
634
+ headers[HeaderTypes.ContentType] ??= MimeTypes.OctetStream;
635
+ }
636
+ else if (Is.string(payload)) {
637
+ body = Converter.utf8ToBytes(payload);
638
+ headers[HeaderTypes.ContentType] ??= `${MimeTypes.PlainText}; charset=utf-8`;
639
+ }
640
+ else {
641
+ body = Converter.utf8ToBytes(JSON.stringify(payload));
642
+ headers[HeaderTypes.ContentType] ??= `${MimeTypes.Json}; charset=utf-8`;
643
+ }
644
+ // A known length lets compression skip small bodies and avoids a chunked response
645
+ headers[HeaderTypes.ContentLength] = body.byteLength.toString();
646
+ return new Response(body, {
647
+ status,
648
+ headers: this.mergeHeaders(headers, multiValueHeaders)
649
+ });
650
+ }
651
+ /**
652
+ * Merge the single value headers in to the multiple value headers, if there are any.
653
+ * @param headers The single value headers.
654
+ * @param multiValueHeaders The multiple value headers.
655
+ * @returns The headers to use for the response.
656
+ * @internal
657
+ */
658
+ mergeHeaders(headers, multiValueHeaders) {
659
+ if (Is.empty(multiValueHeaders)) {
660
+ return headers;
661
+ }
662
+ for (const header of Object.keys(headers)) {
663
+ multiValueHeaders.set(header, headers[header]);
664
+ }
665
+ return multiValueHeaders;
666
+ }
667
+ /**
668
+ * Initialize the cors options.
669
+ * @param options The web server options.
670
+ * @internal
671
+ */
672
+ initCors(options) {
673
+ const corsOptions = this.resolveCorsOptions(options);
674
+ const allowMethods = corsOptions.methods.join(", ");
675
+ const allowHeaders = corsOptions.allowedHeaders.join(", ");
676
+ const exposeHeaders = corsOptions.exposedHeaders.join(", ");
677
+ // Mirrors the Fastify CORS plugin, the headers are added after the handler has run
678
+ // because creating the response headers beforehand forces Hono to rebuild the response
679
+ this._app.use(async (c, next) => {
680
+ const headers = this.getRequestHeaders(c);
681
+ const origin = headers.origin;
682
+ const requestOrigin = Is.string(origin) ? origin : undefined;
683
+ const allowed = corsOptions.hasWildcardOrigin ||
684
+ (Is.stringValue(requestOrigin) && corsOptions.origins.includes(requestOrigin));
685
+ if (allowed && c.req.method === HttpMethod.OPTIONS) {
686
+ if (!Is.stringValue(requestOrigin) ||
687
+ !Is.stringValue(headers["access-control-request-method"])) {
688
+ return c.text("Invalid Preflight Request", HttpStatusCode.badRequest, {
689
+ vary: "Origin"
690
+ });
691
+ }
692
+ return new Response(null, {
693
+ status: HttpStatusCode.noContent,
694
+ headers: {
695
+ vary: "Origin",
696
+ "access-control-allow-origin": requestOrigin,
697
+ "access-control-allow-credentials": "true",
698
+ "access-control-expose-headers": exposeHeaders,
699
+ "access-control-allow-methods": allowMethods,
700
+ "access-control-allow-headers": allowHeaders,
701
+ "content-length": "0"
702
+ }
703
+ });
704
+ }
705
+ await next();
706
+ const responseHeaders = c.res.headers;
707
+ responseHeaders.append("vary", "Origin");
708
+ if (allowed) {
709
+ if (Is.stringValue(requestOrigin)) {
710
+ responseHeaders.set("access-control-allow-origin", requestOrigin);
711
+ }
712
+ responseHeaders.set("access-control-allow-credentials", "true");
713
+ responseHeaders.set("access-control-expose-headers", exposeHeaders);
714
+ }
715
+ });
716
+ }
717
+ /**
718
+ * Listen on a host and port.
719
+ * @param server The server to listen with.
720
+ * @param host The host to bind to.
721
+ * @param port The port to bind to.
722
+ * @returns A promise that resolves when the server is listening.
723
+ * @internal
724
+ */
725
+ async listenOn(server, host, port) {
726
+ await new Promise((resolve, reject) => {
727
+ server.once("error", reject);
728
+ server.listen(port, host, () => {
729
+ server.off("error", reject);
730
+ resolve();
731
+ });
732
+ });
733
+ }
734
+ /**
735
+ * Close the socket server, which also closes the server it is attached to.
736
+ * @param socketServer The socket server to close.
737
+ * @param server The server the socket server is attached to.
738
+ * @returns A promise that resolves when both servers have closed.
739
+ * @internal
740
+ */
741
+ async closeSocketServer(socketServer, server) {
742
+ const serverClosed = Is.empty(server) ? undefined : once(server, "close");
743
+ socketServer.disconnectSockets();
744
+ await socketServer.close();
745
+ await serverClosed;
746
+ }
747
+ /**
748
+ * Close a server.
749
+ * @param server The server to close.
750
+ * @returns A promise that resolves when the server has closed.
751
+ * @internal
752
+ */
753
+ async closeServer(server) {
754
+ await new Promise((resolve, reject) => {
755
+ server.close(err => (Is.empty(err) ? resolve() : reject(err)));
756
+ });
757
+ }
758
+ }
759
+ //# sourceMappingURL=honoWebServer.js.map