@asenajs/hono-adapter 1.6.0 → 2.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.
@@ -6,6 +6,7 @@ import { AsenaAdapter, 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 { brandHonoHttpException, ValidationError } from './errors';
9
10
  import { middlewareParser } from './utils/middlewareParser';
10
11
  import { compareRoutePriority } from './utils/routePriority';
11
12
  import { HttpMethod } from '@asenajs/asena/web-types';
@@ -15,8 +16,17 @@ export class HonoAdapter extends AsenaAdapter {
15
16
  name = 'HonoAdapter';
16
17
  app;
17
18
  _strict = true;
19
+ logErrors = true;
18
20
  server;
19
21
  options = {};
22
+ /**
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
+ */
27
+ hasErrorHandler = false;
28
+ /** Whether the application registered an onNotFound hook (see start()) */
29
+ hasNotFoundHandler = false;
20
30
  /**
21
31
  * Normalizes a route path for Hono compatibility.
22
32
  * Strips trailing slash (except root '/') because Hono's strict:false
@@ -88,15 +98,18 @@ export class HonoAdapter extends AsenaAdapter {
88
98
  if (!this.websocketAdapter.logger && logger) {
89
99
  this.websocketAdapter.logger = this.logger;
90
100
  }
91
- this._strict = typeof loggerOrOptions === 'object' && 'logger' in loggerOrOptions
92
- ? loggerOrOptions.strict ?? true
93
- : true;
101
+ const isOptionsObject = typeof loggerOrOptions === 'object' && 'logger' in loggerOrOptions;
102
+ this._strict = isOptionsObject ? (loggerOrOptions.strict ?? true) : true;
103
+ this.logErrors = isOptionsObject ? (loggerOrOptions.logErrors ?? true) : true;
94
104
  if (honoApp) {
95
105
  this.app = honoApp;
96
106
  }
97
107
  else {
98
108
  this.app = new Hono({ strict: this._strict });
99
109
  }
110
+ // Teach isHttpException() about Hono's own exception class. Doing it here rather than at
111
+ // module scope keeps the side effect tied to actually using the adapter.
112
+ brandHonoHttpException();
100
113
  }
101
114
  /**
102
115
  * Registers a global middleware with optional path filtering
@@ -178,6 +191,20 @@ export class HonoAdapter extends AsenaAdapter {
178
191
  await this.optimizeAndRegisterRoutes();
179
192
  this.routesRegistered = true;
180
193
  }
194
+ // Unconditional 404 default, so an app with no config still answers the same envelope
195
+ // ergenecore does instead of Hono's text/plain. A registered onNotFound has already
196
+ // replaced this - Hono keeps only the last notFound handler, and prepareConfigs runs
197
+ // during APPLICATION_SETUP, before start().
198
+ if (!this.hasNotFoundHandler) {
199
+ this.app.notFound((context) => this.defaultNotFoundResponse(context.req.path, context.req.method));
200
+ }
201
+ // Same reasoning, for errors. `onError` was registered only when the application declared the
202
+ // hook, so an app with no config answered a 500 and wrote nothing to the ServerLogger: Hono's
203
+ // built-in handler dumps a bare stack to stderr with no path, method or status, `logErrors:
204
+ // false` could not suppress it, and a 4xx produced no output at all.
205
+ if (!this.hasErrorHandler) {
206
+ this.app.onError((error, context) => this.defaultErrorResponse(error, context.req.path, context.req.method));
207
+ }
181
208
  this.websocketAdapter.prepareWebSocket(this.options?.wsOptions);
182
209
  const serveConfig = {
183
210
  ...this.options.serveOptions,
@@ -196,6 +223,20 @@ export class HonoAdapter extends AsenaAdapter {
196
223
  // Add HTML routes if any are registered (FrontendController pages)
197
224
  // Bun checks routes first, then falls through to fetch (Hono) for API routes
198
225
  if (this.htmlRoutes.size > 0) {
226
+ // Bun checks `routes` before falling through to `fetch`, so a page registered on a path an
227
+ // API route also serves silently shadows it: the request answers 200 with the HTML and the
228
+ // API route becomes unreachable while still appearing in the startup log. Ergenecore
229
+ // rejects the same collision at startup; match it rather than ship a route that exists in
230
+ // the logs and not on the wire.
231
+ const reservedPaths = new Set([
232
+ ...this.routeQueue.map((route) => route.path),
233
+ ...this.wsRouteQueue.map((route) => (route.path.startsWith('/') ? route.path : `/${route.path}`)),
234
+ ]);
235
+ for (const htmlPath of this.htmlRoutes.keys()) {
236
+ if (reservedPaths.has(htmlPath)) {
237
+ throw new Error(`HTML route collision at "${htmlPath}": path already registered as an API or WebSocket route.`);
238
+ }
239
+ }
199
240
  serveConfig.routes = Object.fromEntries(this.htmlRoutes);
200
241
  }
201
242
  this.server = bun.serve(serveConfig);
@@ -210,74 +251,158 @@ export class HonoAdapter extends AsenaAdapter {
210
251
  return this.server;
211
252
  }
212
253
  /**
213
- * Registers global error handler with enhanced error handling capabilities
254
+ * Registers the handler for requests that match no route.
214
255
  *
215
- * Supports Hono's HTTPException for proper status code handling:
216
- * - HTTPException instances are passed to user handler for custom handling
217
- * - If user handler returns a response, it's used
218
- * - Otherwise, HTTPException's default response is returned
219
- * - Other errors follow normal error handling flow
256
+ * Separate from {@link onError}: an unmatched route is a routing outcome, not a thrown error,
257
+ * so the application's error handler never sees one and never has to discriminate.
220
258
  *
221
- * @param errorHandler - Custom error handler function
259
+ * @param notFoundHandler - Handler that produces the 404 response
260
+ */
261
+ onNotFound(notFoundHandler) {
262
+ this.hasNotFoundHandler = true;
263
+ this.app.notFound(async (context) => {
264
+ const wrapper = new HonoContextWrapper(context, this.server);
265
+ try {
266
+ const response = await notFoundHandler(wrapper, {
267
+ path: context.req.path,
268
+ method: context.req.method,
269
+ });
270
+ if (response instanceof Response) {
271
+ // The application answered its own 404 - it already knows about the request.
272
+ return response;
273
+ }
274
+ }
275
+ catch (error) {
276
+ // onNotFound must not be able to take the server down, and it is deliberately NOT
277
+ // routed to onError - that hook is for errors the application threw itself.
278
+ this.logger.error('onNotFound threw an error, using the default response:', error);
279
+ }
280
+ return this.defaultNotFoundResponse(context.req.path, context.req.method);
281
+ });
282
+ }
283
+ /**
284
+ * The 404 both adapters answer when no `onNotFound` is registered, and the log line that goes
285
+ * with it.
286
+ *
287
+ * Hono's own default is `text/plain`, so the same application used to produce a different
288
+ * body depending on which adapter it ran under - the specific complaint that started this
289
+ * work. Registered unconditionally in `start()` so the default holds even for an app with
290
+ * no config at all.
291
+ *
292
+ * `info`, not `warn`: a scanner walking /wp-admin, /.env and /phpmyadmin must not be able to
293
+ * fill the warning stream. Not `debug` either - a 404 nobody can see is how a mistyped route
294
+ * survives to production. Only reached when the framework is the one answering, so an
295
+ * application that shaped its own 404 gets no line from here.
296
+ */
297
+ defaultNotFoundResponse(path, method) {
298
+ if (this.logErrors !== false) {
299
+ this.logger.info('Route not found:', { path, method, status: 404 });
300
+ }
301
+ return new Response(JSON.stringify({ error: 'Not Found' }), {
302
+ status: 404,
303
+ headers: { 'Content-Type': 'application/json' },
304
+ });
305
+ }
306
+ /**
307
+ * Registers the application's global error handler.
308
+ *
309
+ * Every error the application throws is offered to it first, `HTTPException` included, so an
310
+ * application can reshape its own 401/403/429 envelopes rather than only the 500s. Returning
311
+ * nothing means "not mine, use the default" - the framework then answers, and logs.
312
+ *
313
+ * @param errorHandler - Handler that produces the response for a thrown error
222
314
  *
223
315
  * @example
224
316
  * ```typescript
225
317
  * adapter.onError((error, context) => {
226
- * // HTTPException handling (optional custom behavior)
227
- * if (error instanceof HTTPException) {
228
- * // Add custom logging or return custom response
229
- * return context.send({ custom: 'response' }, error.status);
318
+ * if (isHttpException(error)) {
319
+ * return context.send({ error: error.message }, error.status);
230
320
  * }
231
321
  *
232
- * // Other errors
233
322
  * return context.send({ error: 'Internal error' }, 500);
234
323
  * });
235
324
  * ```
236
325
  */
237
326
  onError(errorHandler) {
327
+ this.hasErrorHandler = true;
238
328
  this.app.onError((error, context) => {
239
- // Log error with full details
240
- this.logger.error('Application error occurred:', {
241
- message: error.message,
242
- stack: error.stack,
243
- path: context.req.path,
244
- method: context.req.method,
245
- timestamp: new Date().toISOString(),
246
- });
247
329
  // Wrap context for user handler
248
330
  const wrapper = new HonoContextWrapper(context, this.server);
249
- // Handle HTTPException with Hono's standard pattern
250
- if (error instanceof HTTPException) {
251
- try {
252
- // Allow user to customize HTTPException handling
253
- const customResponse = errorHandler(error, wrapper);
254
- // If user returned a response, use it
255
- if (customResponse) {
256
- return customResponse;
257
- }
258
- }
259
- catch (handlerError) {
260
- // User handler failed, log and fallback to HTTPException's default response
261
- this.logger.error('Error handler threw an error for HTTPException, using default response:', handlerError);
262
- }
263
- // Return HTTPException's default response (proper status code + message)
264
- return error.getResponse();
265
- }
266
- // Handle other errors through user-defined handler
267
331
  try {
268
- return errorHandler(error, wrapper);
332
+ const customResponse = errorHandler(error, wrapper);
333
+ // A handler that returns nothing is saying "not mine, use the default" - the contract
334
+ // ergenecore's `respondToError` already implements. Passing `undefined` straight to Hono
335
+ // made Bun answer **200** with its "Welcome to Bun!" placeholder, so a failed request was
336
+ // reported to the client, and to every uptime monitor in front of it, as a success.
337
+ if (customResponse) {
338
+ // The application answered. Its handler is where this error gets recorded, with
339
+ // whatever correlation id the application carries - a second line from the adapter
340
+ // would only duplicate it.
341
+ return customResponse;
342
+ }
269
343
  }
270
344
  catch (handlerError) {
271
- // Fallback if error handler itself throws
272
- this.logger.error('Error handler threw an error:', handlerError);
273
- return context.json({
274
- error: 'Internal server error',
275
- message: process.env.NODE_ENV === 'production' ? 'An unexpected error occurred' : error.message,
276
- timestamp: new Date().toISOString(),
277
- }, 500);
345
+ // The application's handler failed. Fall through to the default response.
346
+ this.logger.error('Error handler threw an error, using the default response:', handlerError);
278
347
  }
348
+ return this.defaultErrorResponse(error, context.req.path, context.req.method);
349
+ });
350
+ }
351
+ /**
352
+ * The response the framework itself answers with, and the log line that goes with it.
353
+ *
354
+ * Reached when the application declared no `onError`, when its handler declined by returning
355
+ * nothing, or when the handler threw. All three mean the framework is producing the response,
356
+ * so it is the framework that has to record what happened - otherwise an `onError` that
357
+ * returns `undefined` swallows a 500 with no trace anywhere.
358
+ *
359
+ * The body is the same one `@asenajs/ergenecore` answers. It used to be `text/plain` for an
360
+ * application with no config and `{error, message, timestamp}` for one whose handler declined,
361
+ * so the same failure produced three different envelopes across the two adapters.
362
+ */
363
+ defaultErrorResponse(error, path, method) {
364
+ this.logHandledError(error, path, method);
365
+ if (error instanceof HTTPException) {
366
+ return error.getResponse();
367
+ }
368
+ return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
369
+ status: 500,
370
+ headers: { 'Content-Type': 'application/json' },
279
371
  });
280
372
  }
373
+ /**
374
+ * Logs an error that is about to be handed to the application's error handler.
375
+ *
376
+ * The level is derived from the status the client will actually see: a 4xx is the
377
+ * caller's mistake and the auth/validation layer doing its job, so it must not flood the
378
+ * ERROR stream with stack traces - an unauthenticated request would otherwise be an
379
+ * attacker-controlled log amplifier. Only 5xx carries a stack, because that is the class
380
+ * of failure nobody predicted.
381
+ *
382
+ * `ServerLogger.debug` is optional, so fall back to `info` when an implementation does
383
+ * not provide it.
384
+ */
385
+ logHandledError(error, path, method) {
386
+ if (this.logErrors === false)
387
+ return;
388
+ const status = typeof error.status === 'number' ? error.status : 500;
389
+ const isServerError = status >= 500;
390
+ const meta = {
391
+ message: error.message,
392
+ path,
393
+ method,
394
+ status,
395
+ ...(isServerError ? { stack: error.stack } : {}),
396
+ };
397
+ if (isServerError) {
398
+ this.logger.error('Application error occurred:', meta);
399
+ return;
400
+ }
401
+ // `debug` is optional on ServerLogger, and older @asenajs/asena typings do not declare
402
+ // it at all - read it structurally and fall back to info.
403
+ const debug = this.logger.debug;
404
+ (debug ?? this.logger.info).call(this.logger, 'Request rejected:', meta);
405
+ }
281
406
  async serveOptions(options) {
282
407
  this.options = await options();
283
408
  }
@@ -398,18 +523,32 @@ export class HonoAdapter extends AsenaAdapter {
398
523
  throw new Error(`Invalid Zod schema provided for '${key}' validator`);
399
524
  }
400
525
  }
401
- // Create validator with proper error handling
402
- const validator = zValidator(key, schema, hook ||
403
- ((result, c) => {
404
- // Default hook with better error formatting
405
- if (!result.success) {
406
- return c.json({
407
- error: 'Validation failed',
408
- details: result.error.flatten(),
409
- target: key,
410
- }, 400);
526
+ // The user hook runs first and wins if it answers the request, but it no
527
+ // longer *replaces* the default handling: a hook added for logging or
528
+ // context enrichment used to silently change that route's error contract
529
+ const validator = zValidator(key, schema, async (result, c) => {
530
+ if (hook) {
531
+ const hookResult = await hook(result, c);
532
+ // Only the two forms zValidator itself honours short-circuit here; anything
533
+ // else falls through to the default so the contract stays consistent
534
+ if (hookResult instanceof Response) {
535
+ return hookResult;
536
+ }
537
+ if (hookResult && typeof hookResult === 'object' && 'response' in hookResult) {
538
+ return hookResult;
411
539
  }
412
- }));
540
+ }
541
+ if (result.success) {
542
+ return;
543
+ }
544
+ // zValidator intersects the result union with `{ target }`, which stops TS
545
+ // narrowing it on `success`. The error type is also widened to a
546
+ // `$ZodError | ZodError` union because zValidator accepts zod-core schemas;
547
+ // ours are always classic `z.ZodType` (see ValidationSchema), so the runtime
548
+ // value is a classic ZodError.
549
+ const { error } = result;
550
+ return this.handleValidationFailure(error, key);
551
+ });
413
552
  validators.push(validator);
414
553
  }
415
554
  catch (error) {
@@ -419,6 +558,23 @@ export class HonoAdapter extends AsenaAdapter {
419
558
  }
420
559
  return validators;
421
560
  }
561
+ /**
562
+ * @description Answer a failed validation.
563
+ *
564
+ * Throws a `ValidationError` so the failure travels the same path as every other error and
565
+ * reaches `ConfigService.onError`, which is what the documentation has always described.
566
+ *
567
+ * The adapter used to answer its own 400 here when no error handler was configured, which made
568
+ * a validation failure the one 4xx that reached neither `onError` nor the log. That envelope
569
+ * did not disappear with the branch - it moved onto `ValidationError.getResponse()`, which
570
+ * `defaultErrorResponse` falls back to.
571
+ * @param {ZodError} error - The Zod failure
572
+ * @param {string} target - Which part of the request failed
573
+ * @returns {never} Always throws
574
+ */
575
+ handleValidationFailure(error, target) {
576
+ throw new ValidationError(error, target);
577
+ }
422
578
  /**
423
579
  * Registers global middlewares at the top level of the Hono app
424
580
  *
@@ -488,66 +644,16 @@ export class HonoAdapter extends AsenaAdapter {
488
644
  .map(({ middleware }) => middleware);
489
645
  }
490
646
  /**
491
- * Extracts base path from a route path
492
- * Examples:
493
- * - "/api/users/:id" -> "/api/users"
494
- * - "/users" -> "/users"
495
- * - "/" -> "/"
496
- */
497
- extractBasePath(path) {
498
- // Remove trailing slash
499
- const normalized = path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path;
500
- // Find the last segment without parameters
501
- const segments = normalized.split('/');
502
- const baseSegments = [];
503
- for (const segment of segments) {
504
- if (segment.startsWith(':') || segment.includes('*')) {
505
- break;
506
- }
507
- baseSegments.push(segment);
508
- }
509
- return baseSegments.join('/') || '/';
510
- }
511
- /**
512
- * Groups routes by their base path
513
- * Returns a map of base path -> routes with that base path
514
- */
515
- groupRoutesByBasePath(routes) {
516
- const groups = new Map();
517
- for (const route of routes) {
518
- const basePath = this.extractBasePath(route.path);
519
- if (!groups.has(basePath)) {
520
- groups.set(basePath, []);
521
- }
522
- groups.get(basePath).push(route);
523
- }
524
- return groups;
525
- }
526
- /**
527
- * Finds common middlewares across all routes in a group
528
- * Returns middlewares that appear in ALL routes
529
- */
530
- extractCommonMiddlewares(routes) {
531
- if (routes.length === 0)
532
- return [];
533
- if (routes.length === 1)
534
- return [];
535
- // Get middleware from first route
536
- const firstRouteMiddlewares = routes[0].middlewares || [];
537
- // Find middlewares that exist in all routes
538
- return firstRouteMiddlewares.filter((middleware) => {
539
- return routes.every((route) => {
540
- return (route.middlewares || []).some((mw) => {
541
- // Compare by constructor name (class identity)
542
- return mw.constructor.name === middleware.constructor.name;
543
- });
544
- });
545
- });
546
- }
547
- /**
548
- * Registers routes to Hono app with optimization
549
- * Groups routes by base path and applies common middlewares at group level
550
- * Also registers WebSocket routes
647
+ * Sorts the queued routes and registers each one with its own middlewares.
648
+ *
649
+ * There used to be a "common middleware" optimisation here: routes sharing a base path were
650
+ * mounted on a sub-app and the middlewares they had in common were hoisted to `group.use('*')`.
651
+ * It compared middlewares with `mw.constructor.name`, but by this point a middleware is the
652
+ * plain `{ handle, override }` literal PrepareMiddlewareService builds - so the name was
653
+ * `"Object"` for every one of them and the comparison was always true. Every route in a group
654
+ * got the *first* route's middlewares and had its own filtered away, which silently swapped
655
+ * authorisation guards between sibling routes. Measured at 100 routes it bought nothing, so
656
+ * it is gone rather than repaired.
551
657
  */
552
658
  async optimizeAndRegisterRoutes() {
553
659
  // Register HTTP routes
@@ -557,23 +663,8 @@ export class HonoAdapter extends AsenaAdapter {
557
663
  // from catching static routes like /count or /search.
558
664
  // @see routePriority.ts for the segment-based comparison algorithm.
559
665
  this.routeQueue.sort((a, b) => compareRoutePriority(a.path, b.path));
560
- // Group routes by base path
561
- const routeGroups = this.groupRoutesByBasePath(this.routeQueue);
562
- // Sort groups by the same priority algorithm to ensure correct inter-group order
563
- const sortedGroups = [...routeGroups.entries()].sort(([a], [b]) => compareRoutePriority(a, b));
564
- for (const [basePath, routes] of sortedGroups) {
565
- // Find common middlewares for this group
566
- const commonMiddlewares = this.extractCommonMiddlewares(routes);
567
- if (routes.length > 1 && commonMiddlewares.length > 0) {
568
- // Multiple routes with common middlewares - use grouping
569
- await this.registerRouteGroup(basePath, commonMiddlewares, routes);
570
- }
571
- else {
572
- // Single route or no common middlewares - register individually
573
- for (const route of routes) {
574
- await this.registerRouteDirect(route);
575
- }
576
- }
666
+ for (const route of this.routeQueue) {
667
+ await this.registerRouteDirect(route);
577
668
  }
578
669
  }
579
670
  // Register WebSocket routes
@@ -637,62 +728,6 @@ export class HonoAdapter extends AsenaAdapter {
637
728
  await next(); // Failed
638
729
  });
639
730
  }
640
- /**
641
- * Registers a group of routes with common middlewares at base path level
642
- */
643
- async registerRouteGroup(basePath, commonMiddlewares, routes) {
644
- // Create grouped Hono instance
645
- const group = new Hono({ strict: this._strict });
646
- // Global middlewares are registered at top level via registerGlobalMiddlewaresTopLevel()
647
- // Apply common middlewares to the entire group
648
- const preparedCommonMiddlewares = this.prepareMiddlewares(commonMiddlewares);
649
- if (preparedCommonMiddlewares.length > 0) {
650
- group.use('*', ...preparedCommonMiddlewares);
651
- }
652
- // Method map for group
653
- const groupMethodMap = {
654
- [HttpMethod.GET]: (path, ...handlers) => group.get(path, ...handlers),
655
- [HttpMethod.POST]: (path, ...handlers) => group.post(path, ...handlers),
656
- [HttpMethod.PUT]: (path, ...handlers) => group.put(path, ...handlers),
657
- [HttpMethod.DELETE]: (path, ...handlers) => group.delete(path, ...handlers),
658
- [HttpMethod.PATCH]: (path, ...handlers) => group.patch(path, ...handlers),
659
- [HttpMethod.OPTIONS]: (path, ...handlers) => group.options(path, ...handlers),
660
- [HttpMethod.CONNECT]: (path, ...handlers) => group.on([HttpMethod.CONNECT.toUpperCase()], path, ...handlers),
661
- // HEAD is handled automatically by Hono via GET routes (HTTP spec: HEAD = GET without body).
662
- [HttpMethod.HEAD]: (path, ...handlers) => group.get(path, ...handlers),
663
- [HttpMethod.TRACE]: (path, ...handlers) => group.on([HttpMethod.TRACE.toUpperCase()], path, ...handlers),
664
- };
665
- // Register each route in the group
666
- for (const route of routes) {
667
- // Remove common middlewares from route middlewares
668
- const uniqueMiddlewares = (route.middlewares || []).filter((mw) => !commonMiddlewares.some((common) => common.constructor.name === mw.constructor.name));
669
- const preparedMiddlewares = this.prepareMiddlewares(uniqueMiddlewares);
670
- const validators = route.validator ? await this.prepareValidator(route.validator) : [];
671
- const allMiddlewares = [...validators, ...preparedMiddlewares];
672
- const methodHandler = groupMethodMap[route.method] ??
673
- ((path, ...handlers) => group.on([route.method.toUpperCase()], path, ...handlers));
674
- // Calculate relative path
675
- let relativePath = route.path;
676
- if (route.path.startsWith(basePath) && basePath !== '/') {
677
- relativePath = route.path.slice(basePath.length);
678
- }
679
- if (!relativePath.startsWith('/')) {
680
- relativePath = '/' + relativePath;
681
- }
682
- // Normalize path for Hono trailing slash compatibility
683
- const normalizedRelativePath = this.normalizePath(relativePath);
684
- // Register to group
685
- if (route.staticServe) {
686
- methodHandler(normalizedRelativePath, ...allMiddlewares, serveStatic(this.prepareStaticServeOptions(route.staticServe)));
687
- }
688
- else {
689
- const handler = this.prepareHandler(route.handler);
690
- methodHandler(normalizedRelativePath, ...allMiddlewares, handler);
691
- }
692
- }
693
- // Mount group to main app (normalize basePath too)
694
- this.app.route(this.normalizePath(basePath), group);
695
- }
696
731
  /**
697
732
  * Registers a single route directly without grouping
698
733
  */