@asenajs/hono-adapter 1.7.0 → 3.0.0
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/README.md +23 -3
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/HonoAdapter.d.ts +107 -48
- package/dist/lib/HonoAdapter.js +265 -201
- package/dist/lib/HonoAdapter.js.map +1 -1
- package/dist/lib/HonoContextWrapper.js +9 -2
- package/dist/lib/HonoContextWrapper.js.map +1 -1
- package/dist/lib/HonoWebsocketAdapter.d.ts +35 -3
- package/dist/lib/HonoWebsocketAdapter.js +63 -8
- package/dist/lib/HonoWebsocketAdapter.js.map +1 -1
- package/dist/lib/defaults/ConfigService.d.ts +35 -0
- package/dist/lib/defaults/ConfigService.js +22 -0
- package/dist/lib/defaults/ConfigService.js.map +1 -1
- package/dist/lib/errors.d.ts +89 -8
- package/dist/lib/errors.js +120 -6
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/middlewares/RateLimiterMiddleware.d.ts +13 -2
- package/dist/lib/middlewares/RateLimiterMiddleware.js +31 -2
- package/dist/lib/middlewares/RateLimiterMiddleware.js.map +1 -1
- package/dist/lib/types/HonoAdapterOptions.d.ts +14 -0
- package/dist/lib/types/HonoHandler.d.ts +2 -1
- package/dist/lib/utils/createHonoAdapter.js +1 -4
- package/dist/lib/utils/createHonoAdapter.js.map +1 -1
- package/package.json +15 -8
package/dist/lib/HonoAdapter.js
CHANGED
|
@@ -2,12 +2,11 @@ import { Hono } from 'hono';
|
|
|
2
2
|
import { HTTPException } from 'hono/http-exception';
|
|
3
3
|
import * as bun from 'bun';
|
|
4
4
|
import { HonoContextWrapper } from './HonoContextWrapper';
|
|
5
|
-
import { AsenaAdapter, VALIDATOR_METHODS, } from '@asenajs/asena/adapter';
|
|
5
|
+
import { AsenaAdapter, isHttpException, VALIDATOR_METHODS, } from '@asenajs/asena/adapter';
|
|
6
6
|
import { shouldApplyMiddleware } from '@asenajs/asena/utils';
|
|
7
7
|
import { blue, green, red, yellow } from '@asenajs/asena/logger';
|
|
8
8
|
import { zValidator } from '@hono/zod-validator';
|
|
9
|
-
import {
|
|
10
|
-
import { ValidationError } from './errors';
|
|
9
|
+
import { brandHonoHttpException, ValidationError, warnOnNestedHono } from './errors';
|
|
11
10
|
import { middlewareParser } from './utils/middlewareParser';
|
|
12
11
|
import { compareRoutePriority } from './utils/routePriority';
|
|
13
12
|
import { HttpMethod } from '@asenajs/asena/web-types';
|
|
@@ -17,14 +16,17 @@ export class HonoAdapter extends AsenaAdapter {
|
|
|
17
16
|
name = 'HonoAdapter';
|
|
18
17
|
app;
|
|
19
18
|
_strict = true;
|
|
19
|
+
logErrors = true;
|
|
20
20
|
server;
|
|
21
21
|
options = {};
|
|
22
22
|
/**
|
|
23
|
-
* Whether the application registered an error handler.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* Whether the *application* registered an error handler. Read only by `start()`, to decide
|
|
24
|
+
* whether the framework's own default needs registering - Hono keeps a single `onError`, so
|
|
25
|
+
* registering both would replace the application's.
|
|
26
26
|
*/
|
|
27
27
|
hasErrorHandler = false;
|
|
28
|
+
/** Whether the application registered an onNotFound hook (see start()) */
|
|
29
|
+
hasNotFoundHandler = false;
|
|
28
30
|
/**
|
|
29
31
|
* Normalizes a route path for Hono compatibility.
|
|
30
32
|
* Strips trailing slash (except root '/') because Hono's strict:false
|
|
@@ -37,9 +39,40 @@ export class HonoAdapter extends AsenaAdapter {
|
|
|
37
39
|
}
|
|
38
40
|
return path;
|
|
39
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Stops the HTTP surface and releases everything the adapter owns.
|
|
44
|
+
*
|
|
45
|
+
* The socket goes down first and the WebSocket adapter is torn down after it, for the same
|
|
46
|
+
* reason the core stops the adapter before running `@OnStop`: a transport has to outlive the
|
|
47
|
+
* things that publish through it. Destroying it while a message handler is still running would
|
|
48
|
+
* only turn a shutdown into an error in the log.
|
|
49
|
+
*
|
|
50
|
+
* `finally`, so the two failure directions are both covered - a `server.stop()` that rejects
|
|
51
|
+
* still releases the WebSocket resources, and a WebSocket teardown that fails cannot leave the
|
|
52
|
+
* port bound. A port that survives `stop()` is how a test suite starts failing with
|
|
53
|
+
* EADDRINUSE and how a rolling deploy keeps taking traffic it can no longer serve.
|
|
54
|
+
*
|
|
55
|
+
* @param closeActiveConnections - Whether to drop in-flight connections instead of draining them
|
|
56
|
+
*/
|
|
40
57
|
async stop(closeActiveConnections = true) {
|
|
41
|
-
|
|
42
|
-
|
|
58
|
+
try {
|
|
59
|
+
if (this.server) {
|
|
60
|
+
await this.server.stop(closeActiveConnections);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
// Cast because `AsenaWebsocketAdapter` does not declare `shutdown()` - the constructor only
|
|
65
|
+
// ever accepts a HonoWebsocketAdapter, so the narrowing is the one the public API already
|
|
66
|
+
// guarantees.
|
|
67
|
+
const websocketAdapter = this.websocketAdapter;
|
|
68
|
+
// Guarded rather than awaited bare: shutdown() already contains its own failures, but a
|
|
69
|
+
// throw from here would replace whatever `server.stop()` was reporting.
|
|
70
|
+
try {
|
|
71
|
+
await websocketAdapter?.shutdown();
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
this.logger.error('WebSocket shutdown failed during adapter stop:', error);
|
|
75
|
+
}
|
|
43
76
|
}
|
|
44
77
|
}
|
|
45
78
|
// Deferred route registration
|
|
@@ -96,14 +129,21 @@ export class HonoAdapter extends AsenaAdapter {
|
|
|
96
129
|
if (!this.websocketAdapter.logger && logger) {
|
|
97
130
|
this.websocketAdapter.logger = this.logger;
|
|
98
131
|
}
|
|
99
|
-
|
|
100
|
-
|
|
132
|
+
const isOptionsObject = typeof loggerOrOptions === 'object' && 'logger' in loggerOrOptions;
|
|
133
|
+
this._strict = isOptionsObject ? (loggerOrOptions.strict ?? true) : true;
|
|
134
|
+
this.logErrors = isOptionsObject ? (loggerOrOptions.logErrors ?? true) : true;
|
|
101
135
|
if (honoApp) {
|
|
102
136
|
this.app = honoApp;
|
|
103
137
|
}
|
|
104
138
|
else {
|
|
105
139
|
this.app = new Hono({ strict: this._strict });
|
|
106
140
|
}
|
|
141
|
+
// Teach isHttpException() about Hono's own exception class. Doing it here rather than at
|
|
142
|
+
// module scope keeps the side effect tied to actually using the adapter.
|
|
143
|
+
brandHonoHttpException();
|
|
144
|
+
// ...and say so if that branding cannot possibly be enough, because `hono` resolved to a copy
|
|
145
|
+
// nested under this package rather than to the application's own.
|
|
146
|
+
warnOnNestedHono(this.logger);
|
|
107
147
|
}
|
|
108
148
|
/**
|
|
109
149
|
* Registers a global middleware with optional path filtering
|
|
@@ -185,6 +225,20 @@ export class HonoAdapter extends AsenaAdapter {
|
|
|
185
225
|
await this.optimizeAndRegisterRoutes();
|
|
186
226
|
this.routesRegistered = true;
|
|
187
227
|
}
|
|
228
|
+
// Unconditional 404 default, so an app with no config still answers the same envelope
|
|
229
|
+
// ergenecore does instead of Hono's text/plain. A registered onNotFound has already
|
|
230
|
+
// replaced this - Hono keeps only the last notFound handler, and prepareConfigs runs
|
|
231
|
+
// during APPLICATION_SETUP, before start().
|
|
232
|
+
if (!this.hasNotFoundHandler) {
|
|
233
|
+
this.app.notFound((context) => this.defaultNotFoundResponse(context.req.path, context.req.method));
|
|
234
|
+
}
|
|
235
|
+
// Same reasoning, for errors. `onError` was registered only when the application declared the
|
|
236
|
+
// hook, so an app with no config answered a 500 and wrote nothing to the ServerLogger: Hono's
|
|
237
|
+
// built-in handler dumps a bare stack to stderr with no path, method or status, `logErrors:
|
|
238
|
+
// false` could not suppress it, and a 4xx produced no output at all.
|
|
239
|
+
if (!this.hasErrorHandler) {
|
|
240
|
+
this.app.onError((error, context) => this.defaultErrorResponse(error, context.req.path, context.req.method));
|
|
241
|
+
}
|
|
188
242
|
this.websocketAdapter.prepareWebSocket(this.options?.wsOptions);
|
|
189
243
|
const serveConfig = {
|
|
190
244
|
...this.options.serveOptions,
|
|
@@ -203,6 +257,20 @@ export class HonoAdapter extends AsenaAdapter {
|
|
|
203
257
|
// Add HTML routes if any are registered (FrontendController pages)
|
|
204
258
|
// Bun checks routes first, then falls through to fetch (Hono) for API routes
|
|
205
259
|
if (this.htmlRoutes.size > 0) {
|
|
260
|
+
// Bun checks `routes` before falling through to `fetch`, so a page registered on a path an
|
|
261
|
+
// API route also serves silently shadows it: the request answers 200 with the HTML and the
|
|
262
|
+
// API route becomes unreachable while still appearing in the startup log. Ergenecore
|
|
263
|
+
// rejects the same collision at startup; match it rather than ship a route that exists in
|
|
264
|
+
// the logs and not on the wire.
|
|
265
|
+
const reservedPaths = new Set([
|
|
266
|
+
...this.routeQueue.map((route) => route.path),
|
|
267
|
+
...this.wsRouteQueue.map((route) => (route.path.startsWith('/') ? route.path : `/${route.path}`)),
|
|
268
|
+
]);
|
|
269
|
+
for (const htmlPath of this.htmlRoutes.keys()) {
|
|
270
|
+
if (reservedPaths.has(htmlPath)) {
|
|
271
|
+
throw new Error(`HTML route collision at "${htmlPath}": path already registered as an API or WebSocket route.`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
206
274
|
serveConfig.routes = Object.fromEntries(this.htmlRoutes);
|
|
207
275
|
}
|
|
208
276
|
this.server = bun.serve(serveConfig);
|
|
@@ -217,26 +285,74 @@ export class HonoAdapter extends AsenaAdapter {
|
|
|
217
285
|
return this.server;
|
|
218
286
|
}
|
|
219
287
|
/**
|
|
220
|
-
* Registers
|
|
288
|
+
* Registers the handler for requests that match no route.
|
|
221
289
|
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* - If user handler returns a response, it's used
|
|
225
|
-
* - Otherwise, HTTPException's default response is returned
|
|
226
|
-
* - Other errors follow normal error handling flow
|
|
290
|
+
* Separate from {@link onError}: an unmatched route is a routing outcome, not a thrown error,
|
|
291
|
+
* so the application's error handler never sees one and never has to discriminate.
|
|
227
292
|
*
|
|
228
|
-
* @param
|
|
293
|
+
* @param notFoundHandler - Handler that produces the 404 response
|
|
294
|
+
*/
|
|
295
|
+
onNotFound(notFoundHandler) {
|
|
296
|
+
this.hasNotFoundHandler = true;
|
|
297
|
+
this.app.notFound(async (context) => {
|
|
298
|
+
const wrapper = new HonoContextWrapper(context, this.server);
|
|
299
|
+
try {
|
|
300
|
+
const response = await notFoundHandler(wrapper, {
|
|
301
|
+
path: context.req.path,
|
|
302
|
+
method: context.req.method,
|
|
303
|
+
});
|
|
304
|
+
if (response instanceof Response) {
|
|
305
|
+
// The application answered its own 404 - it already knows about the request.
|
|
306
|
+
return response;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
// onNotFound must not be able to take the server down, and it is deliberately NOT
|
|
311
|
+
// routed to onError - that hook is for errors the application threw itself.
|
|
312
|
+
this.logger.error('onNotFound threw an error, using the default response:', error);
|
|
313
|
+
}
|
|
314
|
+
return this.defaultNotFoundResponse(context.req.path, context.req.method);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* The 404 both adapters answer when no `onNotFound` is registered, and the log line that goes
|
|
319
|
+
* with it.
|
|
320
|
+
*
|
|
321
|
+
* Hono's own default is `text/plain`, so the same application used to produce a different
|
|
322
|
+
* body depending on which adapter it ran under - the specific complaint that started this
|
|
323
|
+
* work. Registered unconditionally in `start()` so the default holds even for an app with
|
|
324
|
+
* no config at all.
|
|
325
|
+
*
|
|
326
|
+
* `info`, not `warn`: a scanner walking /wp-admin, /.env and /phpmyadmin must not be able to
|
|
327
|
+
* fill the warning stream. Not `debug` either - a 404 nobody can see is how a mistyped route
|
|
328
|
+
* survives to production. Only reached when the framework is the one answering, so an
|
|
329
|
+
* application that shaped its own 404 gets no line from here.
|
|
330
|
+
*/
|
|
331
|
+
defaultNotFoundResponse(path, method) {
|
|
332
|
+
if (this.logErrors !== false) {
|
|
333
|
+
this.logger.info('Route not found:', { path, method, status: 404 });
|
|
334
|
+
}
|
|
335
|
+
return new Response(JSON.stringify({ error: 'Not Found' }), {
|
|
336
|
+
status: 404,
|
|
337
|
+
headers: { 'Content-Type': 'application/json' },
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Registers the application's global error handler.
|
|
342
|
+
*
|
|
343
|
+
* Every error the application throws is offered to it first, `HTTPException` included, so an
|
|
344
|
+
* application can reshape its own 401/403/429 envelopes rather than only the 500s. Returning
|
|
345
|
+
* nothing means "not mine, use the default" - the framework then answers, and logs.
|
|
346
|
+
*
|
|
347
|
+
* @param errorHandler - Handler that produces the response for a thrown error
|
|
229
348
|
*
|
|
230
349
|
* @example
|
|
231
350
|
* ```typescript
|
|
232
351
|
* adapter.onError((error, context) => {
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
* // Add custom logging or return custom response
|
|
236
|
-
* return context.send({ custom: 'response' }, error.status);
|
|
352
|
+
* if (isHttpException(error)) {
|
|
353
|
+
* return context.send({ error: error.message }, error.status);
|
|
237
354
|
* }
|
|
238
355
|
*
|
|
239
|
-
* // Other errors
|
|
240
356
|
* return context.send({ error: 'Internal error' }, 500);
|
|
241
357
|
* });
|
|
242
358
|
* ```
|
|
@@ -244,48 +360,119 @@ export class HonoAdapter extends AsenaAdapter {
|
|
|
244
360
|
onError(errorHandler) {
|
|
245
361
|
this.hasErrorHandler = true;
|
|
246
362
|
this.app.onError((error, context) => {
|
|
247
|
-
// Log error with full details
|
|
248
|
-
this.logger.error('Application error occurred:', {
|
|
249
|
-
message: error.message,
|
|
250
|
-
stack: error.stack,
|
|
251
|
-
path: context.req.path,
|
|
252
|
-
method: context.req.method,
|
|
253
|
-
timestamp: new Date().toISOString(),
|
|
254
|
-
});
|
|
255
363
|
// Wrap context for user handler
|
|
256
364
|
const wrapper = new HonoContextWrapper(context, this.server);
|
|
257
|
-
// Handle HTTPException with Hono's standard pattern
|
|
258
|
-
if (error instanceof HTTPException) {
|
|
259
|
-
try {
|
|
260
|
-
// Allow user to customize HTTPException handling
|
|
261
|
-
const customResponse = errorHandler(error, wrapper);
|
|
262
|
-
// If user returned a response, use it
|
|
263
|
-
if (customResponse) {
|
|
264
|
-
return customResponse;
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
catch (handlerError) {
|
|
268
|
-
// User handler failed, log and fallback to HTTPException's default response
|
|
269
|
-
this.logger.error('Error handler threw an error for HTTPException, using default response:', handlerError);
|
|
270
|
-
}
|
|
271
|
-
// Return HTTPException's default response (proper status code + message)
|
|
272
|
-
return error.getResponse();
|
|
273
|
-
}
|
|
274
|
-
// Handle other errors through user-defined handler
|
|
275
365
|
try {
|
|
276
|
-
|
|
366
|
+
const customResponse = errorHandler(error, wrapper);
|
|
367
|
+
// A handler that returns nothing is saying "not mine, use the default" - the contract
|
|
368
|
+
// ergenecore's `respondToError` already implements. Passing `undefined` straight to Hono
|
|
369
|
+
// made Bun answer **200** with its "Welcome to Bun!" placeholder, so a failed request was
|
|
370
|
+
// reported to the client, and to every uptime monitor in front of it, as a success.
|
|
371
|
+
if (customResponse) {
|
|
372
|
+
// The application answered. Its handler is where this error gets recorded, with
|
|
373
|
+
// whatever correlation id the application carries - a second line from the adapter
|
|
374
|
+
// would only duplicate it.
|
|
375
|
+
return customResponse;
|
|
376
|
+
}
|
|
277
377
|
}
|
|
278
378
|
catch (handlerError) {
|
|
279
|
-
//
|
|
280
|
-
this.logger.error('Error handler threw an error:', handlerError);
|
|
281
|
-
return context.json({
|
|
282
|
-
error: 'Internal server error',
|
|
283
|
-
message: process.env.NODE_ENV === 'production' ? 'An unexpected error occurred' : error.message,
|
|
284
|
-
timestamp: new Date().toISOString(),
|
|
285
|
-
}, 500);
|
|
379
|
+
// The application's handler failed. Fall through to the default response.
|
|
380
|
+
this.logger.error('Error handler threw an error, using the default response:', handlerError);
|
|
286
381
|
}
|
|
382
|
+
return this.defaultErrorResponse(error, context.req.path, context.req.method);
|
|
287
383
|
});
|
|
288
384
|
}
|
|
385
|
+
/**
|
|
386
|
+
* The response the framework itself answers with, and the log line that goes with it.
|
|
387
|
+
*
|
|
388
|
+
* Reached when the application declared no `onError`, when its handler declined by returning
|
|
389
|
+
* nothing, or when the handler threw. All three mean the framework is producing the response,
|
|
390
|
+
* so it is the framework that has to record what happened - otherwise an `onError` that
|
|
391
|
+
* returns `undefined` swallows a 500 with no trace anywhere.
|
|
392
|
+
*
|
|
393
|
+
* The body is the same one `@asenajs/ergenecore` answers. It used to be `text/plain` for an
|
|
394
|
+
* application with no config and `{error, message, timestamp}` for one whose handler declined,
|
|
395
|
+
* so the same failure produced three different envelopes across the two adapters.
|
|
396
|
+
*/
|
|
397
|
+
defaultErrorResponse(error, path, method) {
|
|
398
|
+
this.logHandledError(error, path, method);
|
|
399
|
+
// Hono's own class first, and deliberately by `instanceof` rather than by the brand. The brand
|
|
400
|
+
// reaches it only because `brandHonoHttpException()` patched the prototype from this class's
|
|
401
|
+
// constructor; if that call is ever removed, reordered, or defeated by `hono/http-exception`
|
|
402
|
+
// resolving to a second copy inside a middleware, every 401 from `hono/basic-auth`,
|
|
403
|
+
// `hono/bearer-auth`, `hono/jwt` and hono's own validator would silently answer 500. This arm
|
|
404
|
+
// costs one check and cannot be defeated that way.
|
|
405
|
+
if (error instanceof HTTPException) {
|
|
406
|
+
return error.getResponse();
|
|
407
|
+
}
|
|
408
|
+
if (isHttpException(error)) {
|
|
409
|
+
// The class from `@asenajs/asena/adapter` - the one applications throw on both adapters -
|
|
410
|
+
// and any foreign copy that still carries a way to build its own response.
|
|
411
|
+
if (typeof error.getResponse === 'function') {
|
|
412
|
+
return error.getResponse();
|
|
413
|
+
}
|
|
414
|
+
// Branded, but with nothing to build a response from. Only `status` is in the contract, so
|
|
415
|
+
// honour that and withhold the body: an exception this adapter does not recognise is not
|
|
416
|
+
// known to carry a message that is safe to send to the caller.
|
|
417
|
+
return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
|
|
418
|
+
status: error.status,
|
|
419
|
+
headers: { 'Content-Type': 'application/json' },
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
|
|
423
|
+
status: 500,
|
|
424
|
+
headers: { 'Content-Type': 'application/json' },
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* The status the caller will be answered with, for anything thrown.
|
|
429
|
+
*
|
|
430
|
+
* Shared by `defaultErrorResponse` and `logHandledError` so the response and the log line cannot
|
|
431
|
+
* disagree. They used to: the response was chosen by `instanceof HTTPException` while the log
|
|
432
|
+
* level was chosen by duck-typing a numeric `status` off the error. A plain `Error` carrying a
|
|
433
|
+
* stray `.status = 401` was therefore answered 500 and logged at debug as a 4xx, and a branded
|
|
434
|
+
* exception from a second copy was answered 500 and logged as whatever status it carried.
|
|
435
|
+
*/
|
|
436
|
+
resolveErrorStatus(error) {
|
|
437
|
+
if (error instanceof HTTPException)
|
|
438
|
+
return error.status;
|
|
439
|
+
if (isHttpException(error))
|
|
440
|
+
return error.status;
|
|
441
|
+
return 500;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Logs an error that is about to be handed to the application's error handler.
|
|
445
|
+
*
|
|
446
|
+
* The level is derived from the status the client will actually see - via the same
|
|
447
|
+
* `resolveErrorStatus` the response uses, so the two cannot drift. A 4xx is the caller's
|
|
448
|
+
* mistake and the auth/validation layer doing its job, so it must not flood the ERROR stream
|
|
449
|
+
* with stack traces - an unauthenticated request would otherwise be an attacker-controlled log
|
|
450
|
+
* amplifier. Only 5xx carries a stack, because that is the class of failure nobody predicted.
|
|
451
|
+
*
|
|
452
|
+
* `ServerLogger.debug` is optional, so fall back to `info` when an implementation does
|
|
453
|
+
* not provide it.
|
|
454
|
+
*/
|
|
455
|
+
logHandledError(error, path, method) {
|
|
456
|
+
if (this.logErrors === false)
|
|
457
|
+
return;
|
|
458
|
+
const status = this.resolveErrorStatus(error);
|
|
459
|
+
const isServerError = status >= 500;
|
|
460
|
+
const meta = {
|
|
461
|
+
message: error.message,
|
|
462
|
+
path,
|
|
463
|
+
method,
|
|
464
|
+
status,
|
|
465
|
+
...(isServerError ? { stack: error.stack } : {}),
|
|
466
|
+
};
|
|
467
|
+
if (isServerError) {
|
|
468
|
+
this.logger.error('Application error occurred:', meta);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
// `debug` is optional on ServerLogger, and older @asenajs/asena typings do not declare
|
|
472
|
+
// it at all - read it structurally and fall back to info.
|
|
473
|
+
const debug = this.logger.debug;
|
|
474
|
+
(debug ?? this.logger.info).call(this.logger, 'Request rejected:', meta);
|
|
475
|
+
}
|
|
289
476
|
async serveOptions(options) {
|
|
290
477
|
this.options = await options();
|
|
291
478
|
}
|
|
@@ -430,7 +617,7 @@ export class HonoAdapter extends AsenaAdapter {
|
|
|
430
617
|
// ours are always classic `z.ZodType` (see ValidationSchema), so the runtime
|
|
431
618
|
// value is a classic ZodError.
|
|
432
619
|
const { error } = result;
|
|
433
|
-
return this.handleValidationFailure(error, key
|
|
620
|
+
return this.handleValidationFailure(error, key);
|
|
434
621
|
});
|
|
435
622
|
validators.push(validator);
|
|
436
623
|
}
|
|
@@ -444,20 +631,18 @@ export class HonoAdapter extends AsenaAdapter {
|
|
|
444
631
|
/**
|
|
445
632
|
* @description Answer a failed validation.
|
|
446
633
|
*
|
|
447
|
-
* Throws a `ValidationError` so the failure travels the same path as every other
|
|
448
|
-
*
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
634
|
+
* Throws a `ValidationError` so the failure travels the same path as every other error and
|
|
635
|
+
* reaches `ConfigService.onError`, which is what the documentation has always described.
|
|
636
|
+
*
|
|
637
|
+
* The adapter used to answer its own 400 here when no error handler was configured, which made
|
|
638
|
+
* a validation failure the one 4xx that reached neither `onError` nor the log. That envelope
|
|
639
|
+
* did not disappear with the branch - it moved onto `ValidationError.getResponse()`, which
|
|
640
|
+
* `defaultErrorResponse` falls back to.
|
|
452
641
|
* @param {ZodError} error - The Zod failure
|
|
453
642
|
* @param {string} target - Which part of the request failed
|
|
454
|
-
* @
|
|
455
|
-
* @returns {Response} The fallback response, when no error handler is configured
|
|
643
|
+
* @returns {never} Always throws
|
|
456
644
|
*/
|
|
457
|
-
handleValidationFailure(error, target
|
|
458
|
-
if (!this.hasErrorHandler) {
|
|
459
|
-
return context.json({ error: 'Validation failed', details: flattenError(error), target }, 400);
|
|
460
|
-
}
|
|
645
|
+
handleValidationFailure(error, target) {
|
|
461
646
|
throw new ValidationError(error, target);
|
|
462
647
|
}
|
|
463
648
|
/**
|
|
@@ -529,66 +714,16 @@ export class HonoAdapter extends AsenaAdapter {
|
|
|
529
714
|
.map(({ middleware }) => middleware);
|
|
530
715
|
}
|
|
531
716
|
/**
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
* -
|
|
536
|
-
*
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
const segments = normalized.split('/');
|
|
543
|
-
const baseSegments = [];
|
|
544
|
-
for (const segment of segments) {
|
|
545
|
-
if (segment.startsWith(':') || segment.includes('*')) {
|
|
546
|
-
break;
|
|
547
|
-
}
|
|
548
|
-
baseSegments.push(segment);
|
|
549
|
-
}
|
|
550
|
-
return baseSegments.join('/') || '/';
|
|
551
|
-
}
|
|
552
|
-
/**
|
|
553
|
-
* Groups routes by their base path
|
|
554
|
-
* Returns a map of base path -> routes with that base path
|
|
555
|
-
*/
|
|
556
|
-
groupRoutesByBasePath(routes) {
|
|
557
|
-
const groups = new Map();
|
|
558
|
-
for (const route of routes) {
|
|
559
|
-
const basePath = this.extractBasePath(route.path);
|
|
560
|
-
if (!groups.has(basePath)) {
|
|
561
|
-
groups.set(basePath, []);
|
|
562
|
-
}
|
|
563
|
-
groups.get(basePath).push(route);
|
|
564
|
-
}
|
|
565
|
-
return groups;
|
|
566
|
-
}
|
|
567
|
-
/**
|
|
568
|
-
* Finds common middlewares across all routes in a group
|
|
569
|
-
* Returns middlewares that appear in ALL routes
|
|
570
|
-
*/
|
|
571
|
-
extractCommonMiddlewares(routes) {
|
|
572
|
-
if (routes.length === 0)
|
|
573
|
-
return [];
|
|
574
|
-
if (routes.length === 1)
|
|
575
|
-
return [];
|
|
576
|
-
// Get middleware from first route
|
|
577
|
-
const firstRouteMiddlewares = routes[0].middlewares || [];
|
|
578
|
-
// Find middlewares that exist in all routes
|
|
579
|
-
return firstRouteMiddlewares.filter((middleware) => {
|
|
580
|
-
return routes.every((route) => {
|
|
581
|
-
return (route.middlewares || []).some((mw) => {
|
|
582
|
-
// Compare by constructor name (class identity)
|
|
583
|
-
return mw.constructor.name === middleware.constructor.name;
|
|
584
|
-
});
|
|
585
|
-
});
|
|
586
|
-
});
|
|
587
|
-
}
|
|
588
|
-
/**
|
|
589
|
-
* Registers routes to Hono app with optimization
|
|
590
|
-
* Groups routes by base path and applies common middlewares at group level
|
|
591
|
-
* Also registers WebSocket routes
|
|
717
|
+
* Sorts the queued routes and registers each one with its own middlewares.
|
|
718
|
+
*
|
|
719
|
+
* There used to be a "common middleware" optimisation here: routes sharing a base path were
|
|
720
|
+
* mounted on a sub-app and the middlewares they had in common were hoisted to `group.use('*')`.
|
|
721
|
+
* It compared middlewares with `mw.constructor.name`, but by this point a middleware is the
|
|
722
|
+
* plain `{ handle, override }` literal PrepareMiddlewareService builds - so the name was
|
|
723
|
+
* `"Object"` for every one of them and the comparison was always true. Every route in a group
|
|
724
|
+
* got the *first* route's middlewares and had its own filtered away, which silently swapped
|
|
725
|
+
* authorisation guards between sibling routes. Measured at 100 routes it bought nothing, so
|
|
726
|
+
* it is gone rather than repaired.
|
|
592
727
|
*/
|
|
593
728
|
async optimizeAndRegisterRoutes() {
|
|
594
729
|
// Register HTTP routes
|
|
@@ -598,23 +733,8 @@ export class HonoAdapter extends AsenaAdapter {
|
|
|
598
733
|
// from catching static routes like /count or /search.
|
|
599
734
|
// @see routePriority.ts for the segment-based comparison algorithm.
|
|
600
735
|
this.routeQueue.sort((a, b) => compareRoutePriority(a.path, b.path));
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
// Sort groups by the same priority algorithm to ensure correct inter-group order
|
|
604
|
-
const sortedGroups = [...routeGroups.entries()].sort(([a], [b]) => compareRoutePriority(a, b));
|
|
605
|
-
for (const [basePath, routes] of sortedGroups) {
|
|
606
|
-
// Find common middlewares for this group
|
|
607
|
-
const commonMiddlewares = this.extractCommonMiddlewares(routes);
|
|
608
|
-
if (routes.length > 1 && commonMiddlewares.length > 0) {
|
|
609
|
-
// Multiple routes with common middlewares - use grouping
|
|
610
|
-
await this.registerRouteGroup(basePath, commonMiddlewares, routes);
|
|
611
|
-
}
|
|
612
|
-
else {
|
|
613
|
-
// Single route or no common middlewares - register individually
|
|
614
|
-
for (const route of routes) {
|
|
615
|
-
await this.registerRouteDirect(route);
|
|
616
|
-
}
|
|
617
|
-
}
|
|
736
|
+
for (const route of this.routeQueue) {
|
|
737
|
+
await this.registerRouteDirect(route);
|
|
618
738
|
}
|
|
619
739
|
}
|
|
620
740
|
// Register WebSocket routes
|
|
@@ -678,62 +798,6 @@ export class HonoAdapter extends AsenaAdapter {
|
|
|
678
798
|
await next(); // Failed
|
|
679
799
|
});
|
|
680
800
|
}
|
|
681
|
-
/**
|
|
682
|
-
* Registers a group of routes with common middlewares at base path level
|
|
683
|
-
*/
|
|
684
|
-
async registerRouteGroup(basePath, commonMiddlewares, routes) {
|
|
685
|
-
// Create grouped Hono instance
|
|
686
|
-
const group = new Hono({ strict: this._strict });
|
|
687
|
-
// Global middlewares are registered at top level via registerGlobalMiddlewaresTopLevel()
|
|
688
|
-
// Apply common middlewares to the entire group
|
|
689
|
-
const preparedCommonMiddlewares = this.prepareMiddlewares(commonMiddlewares);
|
|
690
|
-
if (preparedCommonMiddlewares.length > 0) {
|
|
691
|
-
group.use('*', ...preparedCommonMiddlewares);
|
|
692
|
-
}
|
|
693
|
-
// Method map for group
|
|
694
|
-
const groupMethodMap = {
|
|
695
|
-
[HttpMethod.GET]: (path, ...handlers) => group.get(path, ...handlers),
|
|
696
|
-
[HttpMethod.POST]: (path, ...handlers) => group.post(path, ...handlers),
|
|
697
|
-
[HttpMethod.PUT]: (path, ...handlers) => group.put(path, ...handlers),
|
|
698
|
-
[HttpMethod.DELETE]: (path, ...handlers) => group.delete(path, ...handlers),
|
|
699
|
-
[HttpMethod.PATCH]: (path, ...handlers) => group.patch(path, ...handlers),
|
|
700
|
-
[HttpMethod.OPTIONS]: (path, ...handlers) => group.options(path, ...handlers),
|
|
701
|
-
[HttpMethod.CONNECT]: (path, ...handlers) => group.on([HttpMethod.CONNECT.toUpperCase()], path, ...handlers),
|
|
702
|
-
// HEAD is handled automatically by Hono via GET routes (HTTP spec: HEAD = GET without body).
|
|
703
|
-
[HttpMethod.HEAD]: (path, ...handlers) => group.get(path, ...handlers),
|
|
704
|
-
[HttpMethod.TRACE]: (path, ...handlers) => group.on([HttpMethod.TRACE.toUpperCase()], path, ...handlers),
|
|
705
|
-
};
|
|
706
|
-
// Register each route in the group
|
|
707
|
-
for (const route of routes) {
|
|
708
|
-
// Remove common middlewares from route middlewares
|
|
709
|
-
const uniqueMiddlewares = (route.middlewares || []).filter((mw) => !commonMiddlewares.some((common) => common.constructor.name === mw.constructor.name));
|
|
710
|
-
const preparedMiddlewares = this.prepareMiddlewares(uniqueMiddlewares);
|
|
711
|
-
const validators = route.validator ? await this.prepareValidator(route.validator) : [];
|
|
712
|
-
const allMiddlewares = [...validators, ...preparedMiddlewares];
|
|
713
|
-
const methodHandler = groupMethodMap[route.method] ??
|
|
714
|
-
((path, ...handlers) => group.on([route.method.toUpperCase()], path, ...handlers));
|
|
715
|
-
// Calculate relative path
|
|
716
|
-
let relativePath = route.path;
|
|
717
|
-
if (route.path.startsWith(basePath) && basePath !== '/') {
|
|
718
|
-
relativePath = route.path.slice(basePath.length);
|
|
719
|
-
}
|
|
720
|
-
if (!relativePath.startsWith('/')) {
|
|
721
|
-
relativePath = '/' + relativePath;
|
|
722
|
-
}
|
|
723
|
-
// Normalize path for Hono trailing slash compatibility
|
|
724
|
-
const normalizedRelativePath = this.normalizePath(relativePath);
|
|
725
|
-
// Register to group
|
|
726
|
-
if (route.staticServe) {
|
|
727
|
-
methodHandler(normalizedRelativePath, ...allMiddlewares, serveStatic(this.prepareStaticServeOptions(route.staticServe)));
|
|
728
|
-
}
|
|
729
|
-
else {
|
|
730
|
-
const handler = this.prepareHandler(route.handler);
|
|
731
|
-
methodHandler(normalizedRelativePath, ...allMiddlewares, handler);
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
// Mount group to main app (normalize basePath too)
|
|
735
|
-
this.app.route(this.normalizePath(basePath), group);
|
|
736
|
-
}
|
|
737
801
|
/**
|
|
738
802
|
* Registers a single route directly without grouping
|
|
739
803
|
*/
|