@asenajs/ergenecore 1.5.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 +20 -5
- package/dist/lib/Ergenecore.d.ts +70 -63
- package/dist/lib/Ergenecore.js +359 -351
- package/dist/lib/Ergenecore.js.map +1 -1
- package/dist/lib/ErgenecoreContextWrapper.js +4 -1
- package/dist/lib/ErgenecoreContextWrapper.js.map +1 -1
- package/dist/lib/ErgenecoreWebsocketAdapter.js +14 -2
- package/dist/lib/ErgenecoreWebsocketAdapter.js.map +1 -1
- package/dist/lib/defaults/ConfigService.d.ts +19 -14
- package/dist/lib/defaults/ConfigService.js +5 -2
- package/dist/lib/defaults/ConfigService.js.map +1 -1
- package/dist/lib/defaults/MiddlewareService.d.ts +6 -2
- package/dist/lib/defaults/MiddlewareService.js.map +1 -1
- package/dist/lib/defaults/ValidationService.js.map +1 -1
- package/dist/lib/errors.d.ts +25 -85
- package/dist/lib/errors.js +34 -95
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/middlewares/RateLimiterMiddleware.d.ts +11 -2
- package/dist/lib/middlewares/RateLimiterMiddleware.js +29 -2
- package/dist/lib/middlewares/RateLimiterMiddleware.js.map +1 -1
- package/dist/lib/stream/SSEStreamWriter.js +6 -1
- package/dist/lib/stream/SSEStreamWriter.js.map +1 -1
- package/dist/lib/stream/StreamWriter.js +9 -2
- package/dist/lib/stream/StreamWriter.js.map +1 -1
- package/dist/lib/utils/factory.d.ts +10 -0
- package/dist/lib/utils/factory.js +2 -2
- package/dist/lib/utils/factory.js.map +1 -1
- package/package.json +15 -8
package/dist/lib/Ergenecore.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import { AsenaAdapter, VALIDATOR_METHODS, } from '@asenajs/asena/adapter';
|
|
1
|
+
import { AsenaAdapter, isHttpException, VALIDATOR_METHODS, } from '@asenajs/asena/adapter';
|
|
2
2
|
import { blue, green, red, yellow } from '@asenajs/asena/logger';
|
|
3
3
|
import { ErgenecoreWebsocketAdapter } from './ErgenecoreWebsocketAdapter';
|
|
4
4
|
import { ErgenecoreContextWrapper } from './ErgenecoreContextWrapper';
|
|
5
5
|
import * as Bun from 'bun';
|
|
6
6
|
import * as path from 'path';
|
|
7
|
-
import {
|
|
8
|
-
import { HttpException, MiddlewareResponseError, ValidationError } from './errors';
|
|
7
|
+
import { MiddlewareResponseError, ValidationError } from './errors';
|
|
9
8
|
import { shouldApplyMiddleware } from '@asenajs/asena/utils';
|
|
10
|
-
import { isValidationError } from '@asenajs/asena/adapter';
|
|
11
9
|
/**
|
|
12
10
|
* Static response headers for performance
|
|
13
11
|
*
|
|
@@ -59,6 +57,15 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
59
57
|
* Error handler function
|
|
60
58
|
*/
|
|
61
59
|
errorHandler;
|
|
60
|
+
/**
|
|
61
|
+
* Handler for requests that matched no route
|
|
62
|
+
*/
|
|
63
|
+
notFoundHandler;
|
|
64
|
+
/**
|
|
65
|
+
* Whether the adapter logs errors before handing them to the application.
|
|
66
|
+
* Set false when your own handler already logs with a correlation id.
|
|
67
|
+
*/
|
|
68
|
+
logErrors = true;
|
|
62
69
|
/**
|
|
63
70
|
* Global middlewares with route configuration
|
|
64
71
|
* Structure: Array<{ middleware, config }>
|
|
@@ -81,9 +88,10 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
81
88
|
* @param logger - Server logger instance
|
|
82
89
|
* @param websocketAdapter - WebSocket adapter instance (optional)
|
|
83
90
|
*/
|
|
84
|
-
constructor(logger, websocketAdapter) {
|
|
91
|
+
constructor(logger, websocketAdapter, logErrors = true) {
|
|
85
92
|
// Call parent constructor with logger and websocketAdapter
|
|
86
93
|
super(logger, websocketAdapter || new ErgenecoreWebsocketAdapter(logger));
|
|
94
|
+
this.logErrors = logErrors;
|
|
87
95
|
}
|
|
88
96
|
/**
|
|
89
97
|
* Registers a WebSocket route (deferred registration)
|
|
@@ -263,12 +271,35 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
263
271
|
/**
|
|
264
272
|
* Stops the server
|
|
265
273
|
*
|
|
274
|
+
* The HTTP socket closes first and the WebSocket layer second - the reverse of the order
|
|
275
|
+
* start() brought them up, since the transport is initialised *after* Bun.serve(). A close
|
|
276
|
+
* handler running during the drain may still broadcast, so the transport has to outlive the
|
|
277
|
+
* sockets that use it.
|
|
278
|
+
*
|
|
266
279
|
* @param closeActiveConnections - Whether to close active connections
|
|
267
280
|
*/
|
|
268
281
|
async stop(closeActiveConnections = true) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
282
|
+
try {
|
|
283
|
+
if (this.server) {
|
|
284
|
+
await this.server.stop(closeActiveConnections);
|
|
285
|
+
this.logger.info('Server stopped');
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
// In a `finally`, and swallowed: `AsenaServer.runStop()` awaits this call unguarded, so an
|
|
290
|
+
// error escaping here would strand everything queued behind it - the components' @OnStop
|
|
291
|
+
// hooks, the microservice transports, ulak. A WebSocket layer that cannot let go of a
|
|
292
|
+
// broker connection is worth a log line, not a shutdown that stops halfway. And a socket
|
|
293
|
+
// that refuses to close must not be able to keep the transport open either.
|
|
294
|
+
try {
|
|
295
|
+
// The cast is the constructor's guarantee: `websocketAdapter` is declared on the base as
|
|
296
|
+
// `AsenaWebsocketAdapter`, which has no shutdown() of its own, but Ergenecore only ever
|
|
297
|
+
// accepts - or defaults to - an ErgenecoreWebsocketAdapter.
|
|
298
|
+
await this.websocketAdapter.shutdown();
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
this.logger.error('WebSocket shutdown failed, continuing with shutdown:', error);
|
|
302
|
+
}
|
|
272
303
|
}
|
|
273
304
|
}
|
|
274
305
|
/**
|
|
@@ -279,6 +310,14 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
279
310
|
onError(errorHandler) {
|
|
280
311
|
this.errorHandler = errorHandler;
|
|
281
312
|
}
|
|
313
|
+
/**
|
|
314
|
+
* Registers the handler for requests that match no route.
|
|
315
|
+
*
|
|
316
|
+
* Separate from {@link onError}: a missing route is a routing outcome, not a thrown error.
|
|
317
|
+
*/
|
|
318
|
+
onNotFound(notFoundHandler) {
|
|
319
|
+
this.notFoundHandler = notFoundHandler;
|
|
320
|
+
}
|
|
282
321
|
/**
|
|
283
322
|
* Sets serve options
|
|
284
323
|
*
|
|
@@ -312,59 +351,52 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
312
351
|
*/
|
|
313
352
|
buildBunRoutes() {
|
|
314
353
|
const routes = {};
|
|
315
|
-
// Group routes by base path
|
|
316
|
-
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const routesByPath = new Map();
|
|
324
|
-
for (const route of groupRoutes) {
|
|
325
|
-
if (!routesByPath.has(route.path)) {
|
|
326
|
-
routesByPath.set(route.path, []);
|
|
327
|
-
}
|
|
328
|
-
routesByPath.get(route.path).push(route);
|
|
354
|
+
// Group routes by exact path. There used to be an outer grouping by *base* path too, but
|
|
355
|
+
// its only product was a `commonMiddlewares` array that createRouteHandler ignored - and
|
|
356
|
+
// the comparison behind it was broken anyway (see the removed extractCommonMiddlewares in
|
|
357
|
+
// HonoAdapter, where the same code was live and swapped guards between sibling routes).
|
|
358
|
+
const routesByPath = new Map();
|
|
359
|
+
for (const route of this.routeQueue) {
|
|
360
|
+
if (!routesByPath.has(route.path)) {
|
|
361
|
+
routesByPath.set(route.path, []);
|
|
329
362
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
363
|
+
routesByPath.get(route.path).push(route);
|
|
364
|
+
}
|
|
365
|
+
// Build Bun router object for each path
|
|
366
|
+
for (const [path, pathRoutes] of routesByPath) {
|
|
367
|
+
// Normalize trailing slash to support both variants
|
|
368
|
+
// This ensures /users and /users/ both work with query params
|
|
369
|
+
const normalizedPath = path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path;
|
|
370
|
+
const pathWithSlash = normalizedPath === '/' ? '/' : `${normalizedPath}/`;
|
|
371
|
+
// Initialize both path variants (skip duplicate for root path)
|
|
372
|
+
routes[normalizedPath] = routes[normalizedPath] || {};
|
|
373
|
+
if (normalizedPath !== '/') {
|
|
374
|
+
routes[pathWithSlash] = routes[pathWithSlash] || {};
|
|
375
|
+
}
|
|
376
|
+
for (const route of pathRoutes) {
|
|
377
|
+
const method = route.method.toUpperCase();
|
|
378
|
+
// Fast Path Optimization with Pattern Matching
|
|
379
|
+
// ✅ Check if this route has any applicable global middlewares
|
|
380
|
+
const applicableGlobalMiddlewares = this.getGlobalMiddlewaresForPath(route.path);
|
|
381
|
+
// Check if route is simple (no middleware, validation, or static serve)
|
|
382
|
+
const isSimpleRoute = applicableGlobalMiddlewares.length === 0 &&
|
|
383
|
+
(!route.middlewares || route.middlewares.length === 0) &&
|
|
384
|
+
!route.validator &&
|
|
385
|
+
!route.staticServe;
|
|
386
|
+
// Create handler based on route complexity
|
|
387
|
+
const handler = isSimpleRoute ? this.createFastPathHandler(route) : this.createRouteHandler(route);
|
|
388
|
+
// Register handler to both path variants for trailing slash support
|
|
389
|
+
if (method === 'ALL') {
|
|
390
|
+
// Bun native: direct handler function matches all HTTP methods
|
|
391
|
+
routes[normalizedPath] = handler;
|
|
392
|
+
if (normalizedPath !== '/') {
|
|
393
|
+
routes[pathWithSlash] = handler;
|
|
362
394
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
routes[normalizedPath][method] = handler;
|
|
398
|
+
if (normalizedPath !== '/') {
|
|
399
|
+
routes[pathWithSlash][method] = handler;
|
|
368
400
|
}
|
|
369
401
|
}
|
|
370
402
|
}
|
|
@@ -374,26 +406,35 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
374
406
|
routes['/*'] = async (req) => {
|
|
375
407
|
const context = new ErgenecoreContextWrapper(req, this.server);
|
|
376
408
|
const requestPath = new URL(req.url).pathname;
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
409
|
+
// Wrapped like every other request path. Without this a global middleware throwing on an
|
|
410
|
+
// unmatched route - an auth middleware raising HttpException(401), or getBody() rejecting a
|
|
411
|
+
// malformed payload - escaped the handler entirely: onError never saw it, nothing was
|
|
412
|
+
// logged, and the caller got Bun's own 500 page instead of the adapter's envelope.
|
|
413
|
+
try {
|
|
414
|
+
// Filter and run applicable global middlewares
|
|
415
|
+
const applicableMiddlewares = this.globalMiddlewares
|
|
416
|
+
.filter(({ config }) => shouldApplyMiddleware(requestPath, config))
|
|
417
|
+
.map(({ middleware }) => middleware);
|
|
418
|
+
if (applicableMiddlewares.length > 0) {
|
|
419
|
+
// The 404 is produced as the chain's *terminal step*, the same way a matched route runs
|
|
420
|
+
// its handler. Producing it after the chain had already unwound meant a global
|
|
421
|
+
// middleware's post-`next()` code observed the request before any status existed -
|
|
422
|
+
// `@asenajs/asena-otel` reads `context.res.status` there, so every unmatched route was
|
|
423
|
+
// recorded with no `http.response.status_code`.
|
|
424
|
+
const result = await this.executeMiddlewares(context, applicableMiddlewares, 0, () => this.respondToUnmatched(context, requestPath, req.method));
|
|
425
|
+
if (result instanceof Response)
|
|
426
|
+
return result;
|
|
427
|
+
if (result === false) {
|
|
428
|
+
return new Response('Forbidden', { status: 403 });
|
|
429
|
+
}
|
|
387
430
|
}
|
|
431
|
+
// Reached when no global middleware applies, or when one short-circuited without calling
|
|
432
|
+
// next(). The application's onNotFound, or the default 404.
|
|
433
|
+
return await this.respondToUnmatched(context, requestPath, req.method);
|
|
388
434
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
return this.errorHandler(new Error('Not Found'), context);
|
|
435
|
+
catch (error) {
|
|
436
|
+
return await this.respondToError(error, context);
|
|
392
437
|
}
|
|
393
|
-
return new Response(JSON.stringify({ error: 'Not Found' }), {
|
|
394
|
-
status: 404,
|
|
395
|
-
headers: STATIC_JSON_HEADERS,
|
|
396
|
-
});
|
|
397
438
|
};
|
|
398
439
|
return routes;
|
|
399
440
|
}
|
|
@@ -428,6 +469,158 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
428
469
|
}
|
|
429
470
|
return routes;
|
|
430
471
|
}
|
|
472
|
+
/**
|
|
473
|
+
* Answers a request nothing served: an unmatched route, or a static file that does not exist.
|
|
474
|
+
*
|
|
475
|
+
* Both go through the application's `onNotFound` so an app has one place to shape its 404,
|
|
476
|
+
* and both fall back to the same JSON envelope the hono adapter uses.
|
|
477
|
+
*
|
|
478
|
+
* Deliberately NOT routed to `onError` - that hook is for errors the application threw.
|
|
479
|
+
*/
|
|
480
|
+
async respondToUnmatched(context, path, method) {
|
|
481
|
+
const response = await this.resolveUnmatchedResponse(context, path, method);
|
|
482
|
+
// Record the status on the context before answering. An unmatched request never reaches a
|
|
483
|
+
// route handler, so nothing else writes it - and `OtelTracingMiddleware` reads
|
|
484
|
+
// `context.res.status` to attribute both the span and the request metrics. Without this every
|
|
485
|
+
// 404 was recorded with no `http.response.status_code` at all, which is exactly the traffic
|
|
486
|
+
// (bots, probes, typo'd paths) an operator most wants to count.
|
|
487
|
+
context.res.status = response.status;
|
|
488
|
+
return response;
|
|
489
|
+
}
|
|
490
|
+
async resolveUnmatchedResponse(context, path, method) {
|
|
491
|
+
if (this.notFoundHandler) {
|
|
492
|
+
try {
|
|
493
|
+
const response = await this.notFoundHandler(context, { path, method });
|
|
494
|
+
if (response instanceof Response) {
|
|
495
|
+
return response;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
// onNotFound must not be able to take the server down.
|
|
500
|
+
this.logger.error('onNotFound threw an error, using the default response:', error);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
this.logUnmatched(path, method);
|
|
504
|
+
return new Response(JSON.stringify({ error: 'Not Found' }), {
|
|
505
|
+
status: 404,
|
|
506
|
+
headers: STATIC_JSON_HEADERS,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Records a request the framework itself answered with a 404.
|
|
511
|
+
*
|
|
512
|
+
* Only reached when the application has no `onNotFound`, or when its hook declined or threw -
|
|
513
|
+
* an application that answered its own 404 already knows about the request. Without this an
|
|
514
|
+
* unmatched route was the one outcome that produced no output at any level, which is exactly
|
|
515
|
+
* the traffic (bots, probes, a typo in a deployed client) an operator needs to see.
|
|
516
|
+
*
|
|
517
|
+
* `info`, not `warn`: a scanner walking /wp-admin, /.env and /phpmyadmin must not be able to
|
|
518
|
+
* fill the warning stream. Not `debug` either - a 404 nobody can see is how a mistyped route
|
|
519
|
+
* survives to production.
|
|
520
|
+
*/
|
|
521
|
+
logUnmatched(path, method) {
|
|
522
|
+
if (this.logErrors === false)
|
|
523
|
+
return;
|
|
524
|
+
this.logger.info('Route not found:', { path, method, status: 404 });
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Logs an error before the application's handler sees it, at a level that matches the
|
|
528
|
+
* response the caller will get.
|
|
529
|
+
*
|
|
530
|
+
* This adapter used to log only when NO error handler was registered - which is to say,
|
|
531
|
+
* never in a real application, since every app configures `onError`. A 500 answered
|
|
532
|
+
* nothing to stdout and the stack was gone. The level split mirrors the hono adapter:
|
|
533
|
+
* 5xx is ours to fix and gets a stack, 4xx is the caller's and would otherwise let a bot
|
|
534
|
+
* scanning for /wp-admin flood the error stream.
|
|
535
|
+
*/
|
|
536
|
+
logHandledError(error, context) {
|
|
537
|
+
if (this.logErrors === false)
|
|
538
|
+
return;
|
|
539
|
+
// Brand check, not `instanceof` - `respondToError` below uses `isHttpException` to decide the
|
|
540
|
+
// *response*, and with two resolved copies of this package `instanceof` disagrees with it: the
|
|
541
|
+
// client would get a correct 401 while this line computed 500 and wrote an error-level entry
|
|
542
|
+
// with a full stack. That is precisely the log flooding the level split exists to prevent.
|
|
543
|
+
const status = isHttpException(error) ? error.status : 500;
|
|
544
|
+
const isServerError = status >= 500;
|
|
545
|
+
const meta = {
|
|
546
|
+
// A thrown non-Error is rare but legal, and `String(value)` on a plain object yields
|
|
547
|
+
// "[object Object]" - which tells an operator nothing. Serialise it instead.
|
|
548
|
+
message: error instanceof Error ? error.message : JSON.stringify(error),
|
|
549
|
+
path: new URL(context.req.url).pathname,
|
|
550
|
+
method: context.req.method,
|
|
551
|
+
status,
|
|
552
|
+
...(isServerError && error instanceof Error ? { stack: error.stack } : {}),
|
|
553
|
+
};
|
|
554
|
+
if (isServerError) {
|
|
555
|
+
this.logger.error('Application error occurred:', meta);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
// `debug` is optional on ServerLogger - read it structurally and fall back to info.
|
|
559
|
+
const debug = this.logger.debug;
|
|
560
|
+
(debug ?? this.logger.info).call(this.logger, 'Request rejected:', meta);
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Produces the response for a thrown error, in the same order the hono adapter uses.
|
|
564
|
+
*
|
|
565
|
+
* The application's handler gets first refusal on *every* error, `HttpException` included.
|
|
566
|
+
* Previously each catch block answered an `HttpException` straight from `getResponse()` and
|
|
567
|
+
* only consulted `errorHandler` for everything else, so an app could not reshape its own
|
|
568
|
+
* 401/403/404 envelopes. Worse, only one of the five catch blocks carried the
|
|
569
|
+
* `!isValidationError` exemption, so a `ValidationError` thrown on the fast path never
|
|
570
|
+
* reached `onError` at all - the exact opposite of why ValidationError exists.
|
|
571
|
+
*
|
|
572
|
+
* Falls back to the exception's own response (or a 500) when there is no handler, when the
|
|
573
|
+
* handler returns nothing, or when the handler itself throws.
|
|
574
|
+
*/
|
|
575
|
+
async respondToError(error, context) {
|
|
576
|
+
if (this.errorHandler) {
|
|
577
|
+
try {
|
|
578
|
+
const response = await this.errorHandler(error, context);
|
|
579
|
+
if (response instanceof Response) {
|
|
580
|
+
// The application answered. Its handler is where this error gets recorded, with
|
|
581
|
+
// whatever correlation id the application carries - a second line from here would
|
|
582
|
+
// only duplicate it.
|
|
583
|
+
return response;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
catch (handlerError) {
|
|
587
|
+
this.logger.error('Error handler threw an error, using the default response:', handlerError);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
// Reached when there is no handler, when it declined, or when it threw - in every one of
|
|
591
|
+
// those the framework is answering, so it is the framework's job to say what happened.
|
|
592
|
+
// Without this, an `onError` that returns nothing swallows a 500 with no trace anywhere.
|
|
593
|
+
this.logHandledError(error, context);
|
|
594
|
+
// Branded check, not `instanceof`: with two resolved copies of this package `instanceof`
|
|
595
|
+
// answers false and every deliberate 401/403/404 would fall through to the generic 500
|
|
596
|
+
// below - silently, because the API still responds.
|
|
597
|
+
if (isHttpException(error)) {
|
|
598
|
+
// `getResponse` is optional on the contract - the brand guarantees `status` and nothing
|
|
599
|
+
// more, so a foreign exception type that carries only the brand is legal. Calling it
|
|
600
|
+
// unconditionally turned such an exception into a TypeError thrown from inside the error
|
|
601
|
+
// path, which is the one place an application cannot recover from.
|
|
602
|
+
if (typeof error.getResponse === 'function') {
|
|
603
|
+
return error.getResponse();
|
|
604
|
+
}
|
|
605
|
+
// Answer from what the contract does guarantee, and nothing more. An exception that reaches
|
|
606
|
+
// here is by definition one this adapter does not understand, so its message is not known to
|
|
607
|
+
// be safe to echo - the same reasoning as the generic 500 below. The status is honoured
|
|
608
|
+
// because that part *is* in the contract, and collapsing a deliberate 429 to a 500 is the
|
|
609
|
+
// failure the brand exists to prevent.
|
|
610
|
+
return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
|
|
611
|
+
status: error.status,
|
|
612
|
+
headers: STATIC_JSON_HEADERS,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
// Never echo the thrown message. An unhandled 500 is by definition something the application
|
|
616
|
+
// did not anticipate, and its message routinely carries a connection string, a file path or a
|
|
617
|
+
// driver's raw complaint. The message is not lost - `logHandledError` above wrote it with a
|
|
618
|
+
// stack. An application that wants to say more declares `onError`.
|
|
619
|
+
return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
|
|
620
|
+
status: 500,
|
|
621
|
+
headers: STATIC_JSON_HEADERS,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
431
624
|
/**
|
|
432
625
|
* Executes middleware chain with recursive next() pattern
|
|
433
626
|
*
|
|
@@ -500,10 +693,9 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
500
693
|
return true;
|
|
501
694
|
}
|
|
502
695
|
catch (error) {
|
|
503
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
}
|
|
696
|
+
// HttpException is deliberately NOT answered here. Converting it to a Response inside the
|
|
697
|
+
// middleware chain short-circuits the application's error handler; rethrowing lets the
|
|
698
|
+
// caller's terminal catch run it through respondToError like every other error.
|
|
507
699
|
// If a downstream middleware returned a Response, propagate it
|
|
508
700
|
if (error instanceof MiddlewareResponseError) {
|
|
509
701
|
return error.response;
|
|
@@ -544,9 +736,10 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
544
736
|
// This happens at server startup, NOT on every request → zero runtime overhead
|
|
545
737
|
const applicableGlobalMiddlewares = this.getGlobalMiddlewaresForPath(wsRoute.path);
|
|
546
738
|
return async (req) => {
|
|
739
|
+
// Built outside the try so the catch can hand it to the application's error handler,
|
|
740
|
+
// the same way createRouteHandler does.
|
|
741
|
+
const context = new ErgenecoreContextWrapper(req, this.server);
|
|
547
742
|
try {
|
|
548
|
-
// Create context wrapper
|
|
549
|
-
const context = new ErgenecoreContextWrapper(req, this.server);
|
|
550
743
|
// Execute filtered global middlewares with real next() chain
|
|
551
744
|
if (applicableGlobalMiddlewares.length > 0) {
|
|
552
745
|
const result = await this.executeMiddlewares(context, applicableGlobalMiddlewares);
|
|
@@ -587,18 +780,7 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
587
780
|
return new Response('WebSocket upgrade failed', { status: 500 });
|
|
588
781
|
}
|
|
589
782
|
catch (error) {
|
|
590
|
-
|
|
591
|
-
if (error instanceof HttpException) {
|
|
592
|
-
return error.getResponse();
|
|
593
|
-
}
|
|
594
|
-
// Log and handle other errors
|
|
595
|
-
this.logger.error('WebSocket upgrade handler error:', error);
|
|
596
|
-
return new Response(JSON.stringify({
|
|
597
|
-
error: error instanceof Error ? error.message : 'WebSocket upgrade failed',
|
|
598
|
-
}), {
|
|
599
|
-
status: 500,
|
|
600
|
-
headers: { 'Content-Type': 'application/json' },
|
|
601
|
-
});
|
|
783
|
+
return await this.respondToError(error, context);
|
|
602
784
|
}
|
|
603
785
|
};
|
|
604
786
|
}
|
|
@@ -684,50 +866,10 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
684
866
|
* @returns Bun-compatible fast path handler
|
|
685
867
|
*/
|
|
686
868
|
createFastPathHandler(route) {
|
|
687
|
-
//
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
context.routePattern = route.path;
|
|
692
|
-
try {
|
|
693
|
-
// Inject Bun's native route params if present
|
|
694
|
-
// @ts-expect-error - Bun adds params to Request
|
|
695
|
-
if (req.params) {
|
|
696
|
-
// @ts-expect-error - Bun adds params to Request
|
|
697
|
-
const params = req.params;
|
|
698
|
-
for (const key in params) {
|
|
699
|
-
context.setValue(`param:${key}`, params[key]);
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
// Execute handler directly
|
|
703
|
-
const response = await route.handler(context);
|
|
704
|
-
// Return response (check if already a Response object)
|
|
705
|
-
if (response instanceof Response) {
|
|
706
|
-
return response;
|
|
707
|
-
}
|
|
708
|
-
// Wrap in Response with static headers
|
|
709
|
-
return new Response(JSON.stringify(response), {
|
|
710
|
-
status: 200,
|
|
711
|
-
headers: STATIC_JSON_HEADERS,
|
|
712
|
-
});
|
|
713
|
-
}
|
|
714
|
-
catch (error) {
|
|
715
|
-
// If handler threw HttpException, convert to Response
|
|
716
|
-
if (error instanceof HttpException) {
|
|
717
|
-
return error.getResponse();
|
|
718
|
-
}
|
|
719
|
-
// Default error handling when no custom error handler is registered
|
|
720
|
-
this.logger.error('Route handler error:', error);
|
|
721
|
-
return new Response(JSON.stringify({
|
|
722
|
-
error: error instanceof Error ? error.message : 'Internal Server Error',
|
|
723
|
-
}), {
|
|
724
|
-
status: 500,
|
|
725
|
-
headers: STATIC_JSON_HEADERS,
|
|
726
|
-
});
|
|
727
|
-
}
|
|
728
|
-
};
|
|
729
|
-
}
|
|
730
|
-
// With error handler (minimal try-catch)
|
|
869
|
+
// Deliberately one closure. There used to be a second, picked here by `if (!this.errorHandler)`
|
|
870
|
+
// - and once both were routed through `respondToError` the two bodies were identical. It was
|
|
871
|
+
// also the only place the adapter read `errorHandler` at *build* time rather than per request,
|
|
872
|
+
// so a handler registered after start() would have selected the wrong closure.
|
|
731
873
|
return async (req) => {
|
|
732
874
|
const context = new ErgenecoreContextWrapper(req, this.server);
|
|
733
875
|
context.routePattern = route.path;
|
|
@@ -754,12 +896,7 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
754
896
|
});
|
|
755
897
|
}
|
|
756
898
|
catch (error) {
|
|
757
|
-
|
|
758
|
-
if (error instanceof HttpException) {
|
|
759
|
-
return error.getResponse();
|
|
760
|
-
}
|
|
761
|
-
// Handle other errors with custom error handler
|
|
762
|
-
return this.errorHandler(error, context);
|
|
899
|
+
return await this.respondToError(error, context);
|
|
763
900
|
}
|
|
764
901
|
};
|
|
765
902
|
}
|
|
@@ -773,10 +910,9 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
773
910
|
* - Error handling
|
|
774
911
|
*
|
|
775
912
|
* @param route - Route parameters
|
|
776
|
-
* @param _commonMiddlewares - Common middlewares for this route group (for future optimization)
|
|
777
913
|
* @returns Bun-compatible route handler
|
|
778
914
|
*/
|
|
779
|
-
createRouteHandler(route
|
|
915
|
+
createRouteHandler(route) {
|
|
780
916
|
// ✅ Filter global middlewares by path pattern (ONCE during route building)
|
|
781
917
|
// This happens at server startup, NOT on every request → zero runtime overhead
|
|
782
918
|
const applicableGlobalMiddlewares = this.getGlobalMiddlewaresForPath(route.path);
|
|
@@ -835,25 +971,8 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
835
971
|
return new Response(null, { status: 204 });
|
|
836
972
|
}
|
|
837
973
|
catch (error) {
|
|
838
|
-
//
|
|
839
|
-
|
|
840
|
-
// branch rather than being answered from getResponse() here
|
|
841
|
-
if (error instanceof HttpException && !isValidationError(error)) {
|
|
842
|
-
return error.getResponse();
|
|
843
|
-
}
|
|
844
|
-
// Handle other errors with custom error handler if available
|
|
845
|
-
if (this.errorHandler) {
|
|
846
|
-
// Pass the original context with params already injected
|
|
847
|
-
return this.errorHandler(error, context);
|
|
848
|
-
}
|
|
849
|
-
// Default error response
|
|
850
|
-
this.logger.error('Route handler error:', error);
|
|
851
|
-
return new Response(JSON.stringify({
|
|
852
|
-
error: error instanceof Error ? error.message : 'Internal Server Error',
|
|
853
|
-
}), {
|
|
854
|
-
status: 500,
|
|
855
|
-
headers: { 'Content-Type': 'application/json' },
|
|
856
|
-
});
|
|
974
|
+
// The context here already has params injected, so pass it through unchanged.
|
|
975
|
+
return await this.respondToError(error, context);
|
|
857
976
|
}
|
|
858
977
|
};
|
|
859
978
|
}
|
|
@@ -924,19 +1043,12 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
924
1043
|
if (hookResponse)
|
|
925
1044
|
return hookResponse;
|
|
926
1045
|
}
|
|
927
|
-
// Reported through the application's error handler so validation shares the
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
error: 'Validation failed',
|
|
934
|
-
details: flattenError(result.error),
|
|
935
|
-
}), {
|
|
936
|
-
status: 400,
|
|
937
|
-
headers: { 'Content-Type': 'application/json' },
|
|
938
|
-
});
|
|
939
|
-
}
|
|
1046
|
+
// Reported through the application's error handler so validation shares the same
|
|
1047
|
+
// response envelope as every other error. The adapter used to answer its own 400 here
|
|
1048
|
+
// when no handler was configured, which meant a validation failure was the one 4xx
|
|
1049
|
+
// that reached neither `onError` nor the log. The envelope did not disappear with that
|
|
1050
|
+
// branch - it moved onto `ValidationError.getResponse()`, which `respondToError`
|
|
1051
|
+
// falls back to.
|
|
940
1052
|
throw new ValidationError(result.error, key);
|
|
941
1053
|
}
|
|
942
1054
|
}
|
|
@@ -1015,84 +1127,82 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
1015
1127
|
* ```
|
|
1016
1128
|
*/
|
|
1017
1129
|
async serveStaticFile(req, context, staticServe) {
|
|
1018
|
-
try
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
}
|
|
1048
|
-
// Default 404 response
|
|
1049
|
-
return new Response('Not Found', { status: 404 });
|
|
1050
|
-
}
|
|
1051
|
-
// 7. File found → trigger onFound hook
|
|
1052
|
-
if (staticServe.onFound) {
|
|
1053
|
-
await staticServe.onFound.handler(rewrittenPath, context);
|
|
1130
|
+
// Deliberately no try/catch. Anything thrown here - a rewriteRequestPath that raises, a
|
|
1131
|
+
// path.resolve or Bun.file failure - travels up to createRouteHandler's catch and through
|
|
1132
|
+
// `respondToError`, like every other failure. Answering it here meant the body was
|
|
1133
|
+
// `{"error": error.message}`, and a message thrown out of the filesystem layer is
|
|
1134
|
+
// specifically a deployment path; and the application's `onError` never saw it at all.
|
|
1135
|
+
// 1. Extract request path from URL
|
|
1136
|
+
const url = new URL(req.url);
|
|
1137
|
+
const requestPath = decodeURIComponent(url.pathname); // Decode URL encoding
|
|
1138
|
+
// 2. Apply path rewriting if provided
|
|
1139
|
+
const rewrittenPath = staticServe.rewriteRequestPath ? staticServe.rewriteRequestPath(requestPath) : requestPath;
|
|
1140
|
+
// 3. Build absolute file path
|
|
1141
|
+
const filePath = path.join(staticServe.root, rewrittenPath);
|
|
1142
|
+
// 4. Security: Resolve and validate path to prevent traversal attacks
|
|
1143
|
+
// Resolve both paths to absolute canonical paths
|
|
1144
|
+
const resolvedFilePath = path.resolve(filePath);
|
|
1145
|
+
const resolvedRoot = path.resolve(staticServe.root);
|
|
1146
|
+
// Check if resolved file path is within root directory
|
|
1147
|
+
if (!resolvedFilePath.startsWith(resolvedRoot)) {
|
|
1148
|
+
this.logger.warn(`Path traversal attempt detected: ${requestPath} -> ${resolvedFilePath}`);
|
|
1149
|
+
return new Response('Forbidden', { status: 403 });
|
|
1150
|
+
}
|
|
1151
|
+
// 5. Create Bun.file() instance
|
|
1152
|
+
const file = Bun.file(resolvedFilePath);
|
|
1153
|
+
// 5. Check if file exists
|
|
1154
|
+
const fileExists = await file.exists();
|
|
1155
|
+
// 6. File not found → trigger onNotFound hook
|
|
1156
|
+
if (!fileExists) {
|
|
1157
|
+
if (staticServe.onNotFound) {
|
|
1158
|
+
await staticServe.onNotFound.handler(rewrittenPath, context);
|
|
1054
1159
|
// If hook overrides, return null (let handler continue)
|
|
1055
|
-
if (staticServe.
|
|
1160
|
+
if (staticServe.onNotFound.override) {
|
|
1056
1161
|
return null;
|
|
1057
1162
|
}
|
|
1058
1163
|
}
|
|
1059
|
-
//
|
|
1060
|
-
|
|
1061
|
-
//
|
|
1062
|
-
|
|
1063
|
-
//
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
for (const [key, value] of Object.entries(customHeaders)) {
|
|
1073
|
-
finalHeaders.set(key, value);
|
|
1074
|
-
}
|
|
1075
|
-
// 12. Add default Cache-Control if not provided
|
|
1076
|
-
// Using 'public, max-age=0' allows caching but requires revalidation
|
|
1077
|
-
if (!finalHeaders.has('Cache-Control')) {
|
|
1078
|
-
finalHeaders.set('Cache-Control', 'public, max-age=0');
|
|
1164
|
+
// No hook, or a hook that did not take over: fall through to the application's
|
|
1165
|
+
// onNotFound, exactly as an unmatched route does. This used to answer a hard-coded
|
|
1166
|
+
// `text/plain` 404 and never consult the config hook - so the same app produced a
|
|
1167
|
+
// different 404 body here than on the hono adapter, where serveStatic calls next()
|
|
1168
|
+
// and the request lands in app.notFound.
|
|
1169
|
+
return await this.respondToUnmatched(context, new URL(req.url).pathname, req.method);
|
|
1170
|
+
}
|
|
1171
|
+
// 7. File found → trigger onFound hook
|
|
1172
|
+
if (staticServe.onFound) {
|
|
1173
|
+
await staticServe.onFound.handler(rewrittenPath, context);
|
|
1174
|
+
// If hook overrides, return null (let handler continue)
|
|
1175
|
+
if (staticServe.onFound.override) {
|
|
1176
|
+
return null;
|
|
1079
1177
|
}
|
|
1080
|
-
// 13. Return response with all headers
|
|
1081
|
-
// This preserves Bun's zero-copy file serving while adding cache headers
|
|
1082
|
-
return new Response(fileResponse.body, {
|
|
1083
|
-
status: fileResponse.status,
|
|
1084
|
-
headers: finalHeaders,
|
|
1085
|
-
});
|
|
1086
1178
|
}
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1179
|
+
// 8. Create Bun file response with automatic Content-Type detection
|
|
1180
|
+
const fileResponse = new Response(file);
|
|
1181
|
+
// 9. Build response headers starting with Bun's Content-Type
|
|
1182
|
+
const finalHeaders = new Headers(fileResponse.headers);
|
|
1183
|
+
// 10. Add cache validation headers (ETag and Last-Modified)
|
|
1184
|
+
// ETag format: W/"<size>-<lastModified>" (weak validator)
|
|
1185
|
+
const etag = `W/"${file.size}-${file.lastModified}"`;
|
|
1186
|
+
finalHeaders.set('ETag', etag);
|
|
1187
|
+
// Last-Modified: HTTP date format (RFC 7231)
|
|
1188
|
+
const lastModified = new Date(file.lastModified).toUTCString();
|
|
1189
|
+
finalHeaders.set('Last-Modified', lastModified);
|
|
1190
|
+
// 11. Add custom headers (can override defaults)
|
|
1191
|
+
const customHeaders = this.buildStaticFileHeaders(resolvedFilePath, staticServe.extra);
|
|
1192
|
+
for (const [key, value] of Object.entries(customHeaders)) {
|
|
1193
|
+
finalHeaders.set(key, value);
|
|
1095
1194
|
}
|
|
1195
|
+
// 12. Add default Cache-Control if not provided
|
|
1196
|
+
// Using 'public, max-age=0' allows caching but requires revalidation
|
|
1197
|
+
if (!finalHeaders.has('Cache-Control')) {
|
|
1198
|
+
finalHeaders.set('Cache-Control', 'public, max-age=0');
|
|
1199
|
+
}
|
|
1200
|
+
// 13. Return response with all headers
|
|
1201
|
+
// This preserves Bun's zero-copy file serving while adding cache headers
|
|
1202
|
+
return new Response(fileResponse.body, {
|
|
1203
|
+
status: fileResponse.status,
|
|
1204
|
+
headers: finalHeaders,
|
|
1205
|
+
});
|
|
1096
1206
|
}
|
|
1097
1207
|
/**
|
|
1098
1208
|
* Builds response headers for static file serving
|
|
@@ -1142,108 +1252,6 @@ export class Ergenecore extends AsenaAdapter {
|
|
|
1142
1252
|
}
|
|
1143
1253
|
return headers;
|
|
1144
1254
|
}
|
|
1145
|
-
/**
|
|
1146
|
-
* Extracts base path from a route path
|
|
1147
|
-
*
|
|
1148
|
-
* Removes dynamic segments (parameters and wildcards) from the path
|
|
1149
|
-
* to find the static base path for grouping routes.
|
|
1150
|
-
*
|
|
1151
|
-
* @param path - Route path (e.g., "/api/users/:id")
|
|
1152
|
-
* @returns Base path without dynamic segments (e.g., "/api/users")
|
|
1153
|
-
*
|
|
1154
|
-
* @example
|
|
1155
|
-
* ```typescript
|
|
1156
|
-
* extractBasePath('/api/users/:id') // => '/api/users'
|
|
1157
|
-
* extractBasePath('/api/users/:id/posts/:postId') // => '/api/users'
|
|
1158
|
-
* extractBasePath('/static/*') // => '/static'
|
|
1159
|
-
* extractBasePath('/') // => '/'
|
|
1160
|
-
* ```
|
|
1161
|
-
*/
|
|
1162
|
-
extractBasePath(path) {
|
|
1163
|
-
// Remove trailing slash (except for root)
|
|
1164
|
-
const normalized = path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path;
|
|
1165
|
-
// Split path into segments
|
|
1166
|
-
const segments = normalized.split('/');
|
|
1167
|
-
const baseSegments = [];
|
|
1168
|
-
// Keep segments until we hit a parameter or wildcard
|
|
1169
|
-
for (const segment of segments) {
|
|
1170
|
-
// Stop if segment is a parameter (:param) or wildcard (*)
|
|
1171
|
-
if (segment.startsWith(':') || segment.includes('*')) {
|
|
1172
|
-
break;
|
|
1173
|
-
}
|
|
1174
|
-
baseSegments.push(segment);
|
|
1175
|
-
}
|
|
1176
|
-
// Join back into path, handle root case
|
|
1177
|
-
return baseSegments.join('/') || '/';
|
|
1178
|
-
}
|
|
1179
|
-
/**
|
|
1180
|
-
* Extracts common middlewares across multiple routes
|
|
1181
|
-
*
|
|
1182
|
-
* Identifies middlewares that are present in ALL routes and can be
|
|
1183
|
-
* moved to group level for optimization.
|
|
1184
|
-
*
|
|
1185
|
-
* @param routes - Array of routes to analyze
|
|
1186
|
-
* @returns Array of common middleware instances
|
|
1187
|
-
*
|
|
1188
|
-
* @example
|
|
1189
|
-
* ```typescript
|
|
1190
|
-
* extractCommonMiddlewares([
|
|
1191
|
-
* { middlewares: [auth, log, rate] },
|
|
1192
|
-
* { middlewares: [auth, log] },
|
|
1193
|
-
* { middlewares: [auth, log] }
|
|
1194
|
-
* ]) // => [auth, log]
|
|
1195
|
-
* ```
|
|
1196
|
-
*/
|
|
1197
|
-
extractCommonMiddlewares(routes) {
|
|
1198
|
-
// Need at least 2 routes to have common middlewares
|
|
1199
|
-
if (routes.length === 0 || routes.length === 1) {
|
|
1200
|
-
return [];
|
|
1201
|
-
}
|
|
1202
|
-
// Get middlewares from first route as baseline
|
|
1203
|
-
const firstRouteMiddlewares = routes[0].middlewares || [];
|
|
1204
|
-
// Filter to only middlewares present in ALL routes
|
|
1205
|
-
return firstRouteMiddlewares.filter((middleware) => {
|
|
1206
|
-
return routes.every((route) => {
|
|
1207
|
-
return (route.middlewares || []).some((mw) => {
|
|
1208
|
-
// Compare by constructor name (class identity)
|
|
1209
|
-
return mw.constructor.name === middleware.constructor.name;
|
|
1210
|
-
});
|
|
1211
|
-
});
|
|
1212
|
-
});
|
|
1213
|
-
}
|
|
1214
|
-
/**
|
|
1215
|
-
* Groups routes by their base path
|
|
1216
|
-
*
|
|
1217
|
-
* Creates a map of base paths to routes for optimization.
|
|
1218
|
-
* Routes with the same base path will share common middlewares.
|
|
1219
|
-
*
|
|
1220
|
-
* @param routes - Array of routes to group
|
|
1221
|
-
* @returns Map of base paths to route arrays
|
|
1222
|
-
*
|
|
1223
|
-
* @example
|
|
1224
|
-
* ```typescript
|
|
1225
|
-
* groupRoutesByBasePath([
|
|
1226
|
-
* { path: '/api/users' },
|
|
1227
|
-
* { path: '/api/users/:id' },
|
|
1228
|
-
* { path: '/api/posts' }
|
|
1229
|
-
* ])
|
|
1230
|
-
* // => Map {
|
|
1231
|
-
* // '/api/users' => [route1, route2],
|
|
1232
|
-
* // '/api/posts' => [route3]
|
|
1233
|
-
* // }
|
|
1234
|
-
* ```
|
|
1235
|
-
*/
|
|
1236
|
-
groupRoutesByBasePath(routes) {
|
|
1237
|
-
const groups = new Map();
|
|
1238
|
-
for (const route of routes) {
|
|
1239
|
-
const basePath = this.extractBasePath(route.path);
|
|
1240
|
-
if (!groups.has(basePath)) {
|
|
1241
|
-
groups.set(basePath, []);
|
|
1242
|
-
}
|
|
1243
|
-
groups.get(basePath).push(route);
|
|
1244
|
-
}
|
|
1245
|
-
return groups;
|
|
1246
|
-
}
|
|
1247
1255
|
get hostname() {
|
|
1248
1256
|
return this._hostname;
|
|
1249
1257
|
}
|