@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.
- package/LICENSE +201 -0
- package/README.md +20 -2
- package/dist/es/honoWebServer.js +759 -0
- package/dist/es/honoWebServer.js.map +1 -0
- package/dist/es/index.js +6 -0
- package/dist/es/index.js.map +1 -0
- package/dist/es/models/IHonoWebServerConfig.js +2 -0
- package/dist/es/models/IHonoWebServerConfig.js.map +1 -0
- package/dist/es/models/IHonoWebServerConstructorOptions.js +2 -0
- package/dist/es/models/IHonoWebServerConstructorOptions.js.map +1 -0
- package/dist/types/honoWebServer.d.ts +74 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/models/IHonoWebServerConfig.d.ts +19 -0
- package/dist/types/models/IHonoWebServerConstructorOptions.d.ts +19 -0
- package/docs/changelog.md +18 -0
- package/docs/examples.md +61 -0
- package/docs/reference/classes/HonoWebServer.md +834 -0
- package/docs/reference/index.md +10 -0
- package/docs/reference/interfaces/IHonoWebServerConfig.md +27 -0
- package/docs/reference/interfaces/IHonoWebServerConstructorOptions.md +27 -0
- package/locales/en.json +25 -0
- package/package.json +54 -6
|
@@ -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
|