@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.
@@ -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 { flattenError } from 'zod';
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. Validation failures are only
24
- * worth throwing when something is there to catch them - otherwise Hono answers 500
25
- * and the adapter's 400 envelope is the better response.
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
- if (this.server) {
42
- await this.server.stop(closeActiveConnections);
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
- this._strict =
100
- typeof loggerOrOptions === 'object' && 'logger' in loggerOrOptions ? (loggerOrOptions.strict ?? true) : true;
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 global error handler with enhanced error handling capabilities
288
+ * Registers the handler for requests that match no route.
221
289
  *
222
- * Supports Hono's HTTPException for proper status code handling:
223
- * - HTTPException instances are passed to user handler for custom handling
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 errorHandler - Custom error handler function
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
- * // HTTPException handling (optional custom behavior)
234
- * if (error instanceof HTTPException) {
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
- return errorHandler(error, wrapper);
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
- // Fallback if error handler itself throws
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, c);
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
- * error and reaches `ConfigService.onError`, which is what the documentation has
449
- * always described. When the application defines no error handler Hono would turn
450
- * a thrown error into a bare 500, so the adapter's own 400 envelope stays as the
451
- * fallback and those applications see no change at all.
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
- * @param {Context} context - The Hono context
455
- * @returns {Response} The fallback response, when no error handler is configured
643
+ * @returns {never} Always throws
456
644
  */
457
- handleValidationFailure(error, target, context) {
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
- * Extracts base path from a route path
533
- * Examples:
534
- * - "/api/users/:id" -> "/api/users"
535
- * - "/users" -> "/users"
536
- * - "/" -> "/"
537
- */
538
- extractBasePath(path) {
539
- // Remove trailing slash
540
- const normalized = path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path;
541
- // Find the last segment without parameters
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
- // Group routes by base path
602
- const routeGroups = this.groupRoutesByBasePath(this.routeQueue);
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
  */