@asenajs/ergenecore 1.4.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.
@@ -1,10 +1,10 @@
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 { HttpException, MiddlewareResponseError } from './errors';
7
+ import { HttpException, MiddlewareResponseError, ValidationError } from './errors';
8
8
  import { shouldApplyMiddleware } from '@asenajs/asena/utils';
9
9
  /**
10
10
  * Static response headers for performance
@@ -57,6 +57,15 @@ export class Ergenecore extends AsenaAdapter {
57
57
  * Error handler function
58
58
  */
59
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;
60
69
  /**
61
70
  * Global middlewares with route configuration
62
71
  * Structure: Array<{ middleware, config }>
@@ -79,9 +88,10 @@ export class Ergenecore extends AsenaAdapter {
79
88
  * @param logger - Server logger instance
80
89
  * @param websocketAdapter - WebSocket adapter instance (optional)
81
90
  */
82
- constructor(logger, websocketAdapter) {
91
+ constructor(logger, websocketAdapter, logErrors = true) {
83
92
  // Call parent constructor with logger and websocketAdapter
84
93
  super(logger, websocketAdapter || new ErgenecoreWebsocketAdapter(logger));
94
+ this.logErrors = logErrors;
85
95
  }
86
96
  /**
87
97
  * Registers a WebSocket route (deferred registration)
@@ -277,6 +287,14 @@ export class Ergenecore extends AsenaAdapter {
277
287
  onError(errorHandler) {
278
288
  this.errorHandler = errorHandler;
279
289
  }
290
+ /**
291
+ * Registers the handler for requests that match no route.
292
+ *
293
+ * Separate from {@link onError}: a missing route is a routing outcome, not a thrown error.
294
+ */
295
+ onNotFound(notFoundHandler) {
296
+ this.notFoundHandler = notFoundHandler;
297
+ }
280
298
  /**
281
299
  * Sets serve options
282
300
  *
@@ -310,59 +328,52 @@ export class Ergenecore extends AsenaAdapter {
310
328
  */
311
329
  buildBunRoutes() {
312
330
  const routes = {};
313
- // Group routes by base path for optimization
314
- const routeGroups = this.groupRoutesByBasePath(this.routeQueue);
315
- // Process each base path group
316
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
317
- for (const [_, groupRoutes] of routeGroups) {
318
- // Extract common middlewares for this group
319
- const commonMiddlewares = this.extractCommonMiddlewares(groupRoutes);
320
- // Group routes by exact path (same as before)
321
- const routesByPath = new Map();
322
- for (const route of groupRoutes) {
323
- if (!routesByPath.has(route.path)) {
324
- routesByPath.set(route.path, []);
325
- }
326
- routesByPath.get(route.path).push(route);
331
+ // Group routes by exact path. There used to be an outer grouping by *base* path too, but
332
+ // its only product was a `commonMiddlewares` array that createRouteHandler ignored - and
333
+ // the comparison behind it was broken anyway (see the removed extractCommonMiddlewares in
334
+ // HonoAdapter, where the same code was live and swapped guards between sibling routes).
335
+ const routesByPath = new Map();
336
+ for (const route of this.routeQueue) {
337
+ if (!routesByPath.has(route.path)) {
338
+ routesByPath.set(route.path, []);
327
339
  }
328
- // Build Bun router object for each path
329
- for (const [path, pathRoutes] of routesByPath) {
330
- // Normalize trailing slash to support both variants
331
- // This ensures /users and /users/ both work with query params
332
- const normalizedPath = path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path;
333
- const pathWithSlash = normalizedPath === '/' ? '/' : `${normalizedPath}/`;
334
- // Initialize both path variants (skip duplicate for root path)
335
- routes[normalizedPath] = routes[normalizedPath] || {};
336
- if (normalizedPath !== '/') {
337
- routes[pathWithSlash] = routes[pathWithSlash] || {};
338
- }
339
- for (const route of pathRoutes) {
340
- const method = route.method.toUpperCase();
341
- // Fast Path Optimization with Pattern Matching
342
- // ✅ Check if this route has any applicable global middlewares
343
- const applicableGlobalMiddlewares = this.getGlobalMiddlewaresForPath(route.path);
344
- // Check if route is simple (no middleware, validation, or static serve)
345
- const isSimpleRoute = applicableGlobalMiddlewares.length === 0 &&
346
- (!route.middlewares || route.middlewares.length === 0) &&
347
- !route.validator &&
348
- !route.staticServe;
349
- // Create handler based on route complexity
350
- const handler = isSimpleRoute
351
- ? this.createFastPathHandler(route)
352
- : this.createRouteHandler(route, commonMiddlewares);
353
- // Register handler to both path variants for trailing slash support
354
- if (method === 'ALL') {
355
- // Bun native: direct handler function matches all HTTP methods
356
- routes[normalizedPath] = handler;
357
- if (normalizedPath !== '/') {
358
- routes[pathWithSlash] = handler;
359
- }
340
+ routesByPath.get(route.path).push(route);
341
+ }
342
+ // Build Bun router object for each path
343
+ for (const [path, pathRoutes] of routesByPath) {
344
+ // Normalize trailing slash to support both variants
345
+ // This ensures /users and /users/ both work with query params
346
+ const normalizedPath = path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path;
347
+ const pathWithSlash = normalizedPath === '/' ? '/' : `${normalizedPath}/`;
348
+ // Initialize both path variants (skip duplicate for root path)
349
+ routes[normalizedPath] = routes[normalizedPath] || {};
350
+ if (normalizedPath !== '/') {
351
+ routes[pathWithSlash] = routes[pathWithSlash] || {};
352
+ }
353
+ for (const route of pathRoutes) {
354
+ const method = route.method.toUpperCase();
355
+ // Fast Path Optimization with Pattern Matching
356
+ // ✅ Check if this route has any applicable global middlewares
357
+ const applicableGlobalMiddlewares = this.getGlobalMiddlewaresForPath(route.path);
358
+ // Check if route is simple (no middleware, validation, or static serve)
359
+ const isSimpleRoute = applicableGlobalMiddlewares.length === 0 &&
360
+ (!route.middlewares || route.middlewares.length === 0) &&
361
+ !route.validator &&
362
+ !route.staticServe;
363
+ // Create handler based on route complexity
364
+ const handler = isSimpleRoute ? this.createFastPathHandler(route) : this.createRouteHandler(route);
365
+ // Register handler to both path variants for trailing slash support
366
+ if (method === 'ALL') {
367
+ // Bun native: direct handler function matches all HTTP methods
368
+ routes[normalizedPath] = handler;
369
+ if (normalizedPath !== '/') {
370
+ routes[pathWithSlash] = handler;
360
371
  }
361
- else {
362
- routes[normalizedPath][method] = handler;
363
- if (normalizedPath !== '/') {
364
- routes[pathWithSlash][method] = handler;
365
- }
372
+ }
373
+ else {
374
+ routes[normalizedPath][method] = handler;
375
+ if (normalizedPath !== '/') {
376
+ routes[pathWithSlash][method] = handler;
366
377
  }
367
378
  }
368
379
  }
@@ -372,26 +383,35 @@ export class Ergenecore extends AsenaAdapter {
372
383
  routes['/*'] = async (req) => {
373
384
  const context = new ErgenecoreContextWrapper(req, this.server);
374
385
  const requestPath = new URL(req.url).pathname;
375
- // Filter and run applicable global middlewares
376
- const applicableMiddlewares = this.globalMiddlewares
377
- .filter(({ config }) => shouldApplyMiddleware(requestPath, config))
378
- .map(({ middleware }) => middleware);
379
- if (applicableMiddlewares.length > 0) {
380
- const result = await this.executeMiddlewares(context, applicableMiddlewares);
381
- if (result instanceof Response)
382
- return result;
383
- if (result === false) {
384
- return new Response('Forbidden', { status: 403 });
386
+ // Wrapped like every other request path. Without this a global middleware throwing on an
387
+ // unmatched route - an auth middleware raising HttpException(401), or getBody() rejecting a
388
+ // malformed payload - escaped the handler entirely: onError never saw it, nothing was
389
+ // logged, and the caller got Bun's own 500 page instead of the adapter's envelope.
390
+ try {
391
+ // Filter and run applicable global middlewares
392
+ const applicableMiddlewares = this.globalMiddlewares
393
+ .filter(({ config }) => shouldApplyMiddleware(requestPath, config))
394
+ .map(({ middleware }) => middleware);
395
+ if (applicableMiddlewares.length > 0) {
396
+ // The 404 is produced as the chain's *terminal step*, the same way a matched route runs
397
+ // its handler. Producing it after the chain had already unwound meant a global
398
+ // middleware's post-`next()` code observed the request before any status existed -
399
+ // `@asenajs/asena-otel` reads `context.res.status` there, so every unmatched route was
400
+ // recorded with no `http.response.status_code`.
401
+ const result = await this.executeMiddlewares(context, applicableMiddlewares, 0, () => this.respondToUnmatched(context, requestPath, req.method));
402
+ if (result instanceof Response)
403
+ return result;
404
+ if (result === false) {
405
+ return new Response('Forbidden', { status: 403 });
406
+ }
385
407
  }
408
+ // Reached when no global middleware applies, or when one short-circuited without calling
409
+ // next(). The application's onNotFound, or the default 404.
410
+ return await this.respondToUnmatched(context, requestPath, req.method);
386
411
  }
387
- // No middleware handled the request → error handler or 404
388
- if (this.errorHandler) {
389
- return this.errorHandler(new Error('Not Found'), context);
412
+ catch (error) {
413
+ return await this.respondToError(error, context);
390
414
  }
391
- return new Response(JSON.stringify({ error: 'Not Found' }), {
392
- status: 404,
393
- headers: STATIC_JSON_HEADERS,
394
- });
395
415
  };
396
416
  return routes;
397
417
  }
@@ -426,6 +446,143 @@ export class Ergenecore extends AsenaAdapter {
426
446
  }
427
447
  return routes;
428
448
  }
449
+ /**
450
+ * Answers a request nothing served: an unmatched route, or a static file that does not exist.
451
+ *
452
+ * Both go through the application's `onNotFound` so an app has one place to shape its 404,
453
+ * and both fall back to the same JSON envelope the hono adapter uses.
454
+ *
455
+ * Deliberately NOT routed to `onError` - that hook is for errors the application threw.
456
+ */
457
+ async respondToUnmatched(context, path, method) {
458
+ const response = await this.resolveUnmatchedResponse(context, path, method);
459
+ // Record the status on the context before answering. An unmatched request never reaches a
460
+ // route handler, so nothing else writes it - and `OtelTracingMiddleware` reads
461
+ // `context.res.status` to attribute both the span and the request metrics. Without this every
462
+ // 404 was recorded with no `http.response.status_code` at all, which is exactly the traffic
463
+ // (bots, probes, typo'd paths) an operator most wants to count.
464
+ context.res.status = response.status;
465
+ return response;
466
+ }
467
+ async resolveUnmatchedResponse(context, path, method) {
468
+ if (this.notFoundHandler) {
469
+ try {
470
+ const response = await this.notFoundHandler(context, { path, method });
471
+ if (response instanceof Response) {
472
+ return response;
473
+ }
474
+ }
475
+ catch (error) {
476
+ // onNotFound must not be able to take the server down.
477
+ this.logger.error('onNotFound threw an error, using the default response:', error);
478
+ }
479
+ }
480
+ this.logUnmatched(path, method);
481
+ return new Response(JSON.stringify({ error: 'Not Found' }), {
482
+ status: 404,
483
+ headers: STATIC_JSON_HEADERS,
484
+ });
485
+ }
486
+ /**
487
+ * Records a request the framework itself answered with a 404.
488
+ *
489
+ * Only reached when the application has no `onNotFound`, or when its hook declined or threw -
490
+ * an application that answered its own 404 already knows about the request. Without this an
491
+ * unmatched route was the one outcome that produced no output at any level, which is exactly
492
+ * the traffic (bots, probes, a typo in a deployed client) an operator needs to see.
493
+ *
494
+ * `info`, not `warn`: a scanner walking /wp-admin, /.env and /phpmyadmin must not be able to
495
+ * fill the warning stream. Not `debug` either - a 404 nobody can see is how a mistyped route
496
+ * survives to production.
497
+ */
498
+ logUnmatched(path, method) {
499
+ if (this.logErrors === false)
500
+ return;
501
+ this.logger.info('Route not found:', { path, method, status: 404 });
502
+ }
503
+ /**
504
+ * Logs an error before the application's handler sees it, at a level that matches the
505
+ * response the caller will get.
506
+ *
507
+ * This adapter used to log only when NO error handler was registered - which is to say,
508
+ * never in a real application, since every app configures `onError`. A 500 answered
509
+ * nothing to stdout and the stack was gone. The level split mirrors the hono adapter:
510
+ * 5xx is ours to fix and gets a stack, 4xx is the caller's and would otherwise let a bot
511
+ * scanning for /wp-admin flood the error stream.
512
+ */
513
+ logHandledError(error, context) {
514
+ if (this.logErrors === false)
515
+ return;
516
+ // Brand check, not `instanceof` - `respondToError` below uses `isHttpException` to decide the
517
+ // *response*, and with two resolved copies of this package `instanceof` disagrees with it: the
518
+ // client would get a correct 401 while this line computed 500 and wrote an error-level entry
519
+ // with a full stack. That is precisely the log flooding the level split exists to prevent.
520
+ const status = isHttpException(error) ? error.status : 500;
521
+ const isServerError = status >= 500;
522
+ const meta = {
523
+ // A thrown non-Error is rare but legal, and `String(value)` on a plain object yields
524
+ // "[object Object]" - which tells an operator nothing. Serialise it instead.
525
+ message: error instanceof Error ? error.message : JSON.stringify(error),
526
+ path: new URL(context.req.url).pathname,
527
+ method: context.req.method,
528
+ status,
529
+ ...(isServerError && error instanceof Error ? { stack: error.stack } : {}),
530
+ };
531
+ if (isServerError) {
532
+ this.logger.error('Application error occurred:', meta);
533
+ return;
534
+ }
535
+ // `debug` is optional on ServerLogger - read it structurally and fall back to info.
536
+ const debug = this.logger.debug;
537
+ (debug ?? this.logger.info).call(this.logger, 'Request rejected:', meta);
538
+ }
539
+ /**
540
+ * Produces the response for a thrown error, in the same order the hono adapter uses.
541
+ *
542
+ * The application's handler gets first refusal on *every* error, `HttpException` included.
543
+ * Previously each catch block answered an `HttpException` straight from `getResponse()` and
544
+ * only consulted `errorHandler` for everything else, so an app could not reshape its own
545
+ * 401/403/404 envelopes. Worse, only one of the five catch blocks carried the
546
+ * `!isValidationError` exemption, so a `ValidationError` thrown on the fast path never
547
+ * reached `onError` at all - the exact opposite of why ValidationError exists.
548
+ *
549
+ * Falls back to the exception's own response (or a 500) when there is no handler, when the
550
+ * handler returns nothing, or when the handler itself throws.
551
+ */
552
+ async respondToError(error, context) {
553
+ if (this.errorHandler) {
554
+ try {
555
+ const response = await this.errorHandler(error, context);
556
+ if (response instanceof Response) {
557
+ // The application answered. Its handler is where this error gets recorded, with
558
+ // whatever correlation id the application carries - a second line from here would
559
+ // only duplicate it.
560
+ return response;
561
+ }
562
+ }
563
+ catch (handlerError) {
564
+ this.logger.error('Error handler threw an error, using the default response:', handlerError);
565
+ }
566
+ }
567
+ // Reached when there is no handler, when it declined, or when it threw - in every one of
568
+ // those the framework is answering, so it is the framework's job to say what happened.
569
+ // Without this, an `onError` that returns nothing swallows a 500 with no trace anywhere.
570
+ this.logHandledError(error, context);
571
+ // Branded check, not `instanceof`: with two resolved copies of this package `instanceof`
572
+ // answers false and every deliberate 401/403/404 would fall through to the generic 500
573
+ // below - silently, because the API still responds.
574
+ if (isHttpException(error)) {
575
+ return error.getResponse();
576
+ }
577
+ // Never echo the thrown message. An unhandled 500 is by definition something the application
578
+ // did not anticipate, and its message routinely carries a connection string, a file path or a
579
+ // driver's raw complaint. The message is not lost - `logHandledError` above wrote it with a
580
+ // stack. An application that wants to say more declares `onError`.
581
+ return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
582
+ status: 500,
583
+ headers: STATIC_JSON_HEADERS,
584
+ });
585
+ }
429
586
  /**
430
587
  * Executes middleware chain with recursive next() pattern
431
588
  *
@@ -498,10 +655,9 @@ export class Ergenecore extends AsenaAdapter {
498
655
  return true;
499
656
  }
500
657
  catch (error) {
501
- // If middleware threw HttpException, convert to Response
502
- if (error instanceof HttpException) {
503
- return error.getResponse();
504
- }
658
+ // HttpException is deliberately NOT answered here. Converting it to a Response inside the
659
+ // middleware chain short-circuits the application's error handler; rethrowing lets the
660
+ // caller's terminal catch run it through respondToError like every other error.
505
661
  // If a downstream middleware returned a Response, propagate it
506
662
  if (error instanceof MiddlewareResponseError) {
507
663
  return error.response;
@@ -542,9 +698,10 @@ export class Ergenecore extends AsenaAdapter {
542
698
  // This happens at server startup, NOT on every request → zero runtime overhead
543
699
  const applicableGlobalMiddlewares = this.getGlobalMiddlewaresForPath(wsRoute.path);
544
700
  return async (req) => {
701
+ // Built outside the try so the catch can hand it to the application's error handler,
702
+ // the same way createRouteHandler does.
703
+ const context = new ErgenecoreContextWrapper(req, this.server);
545
704
  try {
546
- // Create context wrapper
547
- const context = new ErgenecoreContextWrapper(req, this.server);
548
705
  // Execute filtered global middlewares with real next() chain
549
706
  if (applicableGlobalMiddlewares.length > 0) {
550
707
  const result = await this.executeMiddlewares(context, applicableGlobalMiddlewares);
@@ -585,18 +742,7 @@ export class Ergenecore extends AsenaAdapter {
585
742
  return new Response('WebSocket upgrade failed', { status: 500 });
586
743
  }
587
744
  catch (error) {
588
- // If HttpException was thrown, convert to Response
589
- if (error instanceof HttpException) {
590
- return error.getResponse();
591
- }
592
- // Log and handle other errors
593
- this.logger.error('WebSocket upgrade handler error:', error);
594
- return new Response(JSON.stringify({
595
- error: error instanceof Error ? error.message : 'WebSocket upgrade failed',
596
- }), {
597
- status: 500,
598
- headers: { 'Content-Type': 'application/json' },
599
- });
745
+ return await this.respondToError(error, context);
600
746
  }
601
747
  };
602
748
  }
@@ -682,50 +828,10 @@ export class Ergenecore extends AsenaAdapter {
682
828
  * @returns Bun-compatible fast path handler
683
829
  */
684
830
  createFastPathHandler(route) {
685
- // If no error handler is set, use ultra-minimal version with default error handling
686
- if (!this.errorHandler) {
687
- return async (req) => {
688
- const context = new ErgenecoreContextWrapper(req, this.server);
689
- context.routePattern = route.path;
690
- try {
691
- // Inject Bun's native route params if present
692
- // @ts-expect-error - Bun adds params to Request
693
- if (req.params) {
694
- // @ts-expect-error - Bun adds params to Request
695
- const params = req.params;
696
- for (const key in params) {
697
- context.setValue(`param:${key}`, params[key]);
698
- }
699
- }
700
- // Execute handler directly
701
- const response = await route.handler(context);
702
- // Return response (check if already a Response object)
703
- if (response instanceof Response) {
704
- return response;
705
- }
706
- // Wrap in Response with static headers
707
- return new Response(JSON.stringify(response), {
708
- status: 200,
709
- headers: STATIC_JSON_HEADERS,
710
- });
711
- }
712
- catch (error) {
713
- // If handler threw HttpException, convert to Response
714
- if (error instanceof HttpException) {
715
- return error.getResponse();
716
- }
717
- // Default error handling when no custom error handler is registered
718
- this.logger.error('Route handler error:', error);
719
- return new Response(JSON.stringify({
720
- error: error instanceof Error ? error.message : 'Internal Server Error',
721
- }), {
722
- status: 500,
723
- headers: STATIC_JSON_HEADERS,
724
- });
725
- }
726
- };
727
- }
728
- // With error handler (minimal try-catch)
831
+ // Deliberately one closure. There used to be a second, picked here by `if (!this.errorHandler)`
832
+ // - and once both were routed through `respondToError` the two bodies were identical. It was
833
+ // also the only place the adapter read `errorHandler` at *build* time rather than per request,
834
+ // so a handler registered after start() would have selected the wrong closure.
729
835
  return async (req) => {
730
836
  const context = new ErgenecoreContextWrapper(req, this.server);
731
837
  context.routePattern = route.path;
@@ -752,12 +858,7 @@ export class Ergenecore extends AsenaAdapter {
752
858
  });
753
859
  }
754
860
  catch (error) {
755
- // If handler threw HttpException, convert to Response
756
- if (error instanceof HttpException) {
757
- return error.getResponse();
758
- }
759
- // Handle other errors with custom error handler
760
- return this.errorHandler(error, context);
861
+ return await this.respondToError(error, context);
761
862
  }
762
863
  };
763
864
  }
@@ -771,10 +872,9 @@ export class Ergenecore extends AsenaAdapter {
771
872
  * - Error handling
772
873
  *
773
874
  * @param route - Route parameters
774
- * @param _commonMiddlewares - Common middlewares for this route group (for future optimization)
775
875
  * @returns Bun-compatible route handler
776
876
  */
777
- createRouteHandler(route, _commonMiddlewares = []) {
877
+ createRouteHandler(route) {
778
878
  // ✅ Filter global middlewares by path pattern (ONCE during route building)
779
879
  // This happens at server startup, NOT on every request → zero runtime overhead
780
880
  const applicableGlobalMiddlewares = this.getGlobalMiddlewaresForPath(route.path);
@@ -793,10 +893,7 @@ export class Ergenecore extends AsenaAdapter {
793
893
  }
794
894
  try {
795
895
  // Combine global + route middlewares into a single chain
796
- const allMiddlewares = [
797
- ...applicableGlobalMiddlewares,
798
- ...(route.middlewares || []),
799
- ];
896
+ const allMiddlewares = [...applicableGlobalMiddlewares, ...(route.middlewares || [])];
800
897
  // Execute middleware chain with handler as onComplete callback
801
898
  // This ensures the handler runs INSIDE the middleware async context
802
899
  const result = await this.executeMiddlewares(context, allMiddlewares, 0, async () => {
@@ -836,23 +933,8 @@ export class Ergenecore extends AsenaAdapter {
836
933
  return new Response(null, { status: 204 });
837
934
  }
838
935
  catch (error) {
839
- // If handler or middleware threw HttpException, convert to Response
840
- if (error instanceof HttpException) {
841
- return error.getResponse();
842
- }
843
- // Handle other errors with custom error handler if available
844
- if (this.errorHandler) {
845
- // Pass the original context with params already injected
846
- return this.errorHandler(error, context);
847
- }
848
- // Default error response
849
- this.logger.error('Route handler error:', error);
850
- return new Response(JSON.stringify({
851
- error: error instanceof Error ? error.message : 'Internal Server Error',
852
- }), {
853
- status: 500,
854
- headers: { 'Content-Type': 'application/json' },
855
- });
936
+ // The context here already has params injected, so pass it through unchanged.
937
+ return await this.respondToError(error, context);
856
938
  }
857
939
  };
858
940
  }
@@ -923,14 +1005,13 @@ export class Ergenecore extends AsenaAdapter {
923
1005
  if (hookResponse)
924
1006
  return hookResponse;
925
1007
  }
926
- // Default error response
927
- return new Response(JSON.stringify({
928
- error: 'Validation failed',
929
- details: result.error.flatten(),
930
- }), {
931
- status: 400,
932
- headers: { 'Content-Type': 'application/json' },
933
- });
1008
+ // Reported through the application's error handler so validation shares the same
1009
+ // response envelope as every other error. The adapter used to answer its own 400 here
1010
+ // when no handler was configured, which meant a validation failure was the one 4xx
1011
+ // that reached neither `onError` nor the log. The envelope did not disappear with that
1012
+ // branch - it moved onto `ValidationError.getResponse()`, which `respondToError`
1013
+ // falls back to.
1014
+ throw new ValidationError(result.error, key);
934
1015
  }
935
1016
  }
936
1017
  // All validations passed
@@ -1008,84 +1089,82 @@ export class Ergenecore extends AsenaAdapter {
1008
1089
  * ```
1009
1090
  */
1010
1091
  async serveStaticFile(req, context, staticServe) {
1011
- try {
1012
- // 1. Extract request path from URL
1013
- const url = new URL(req.url);
1014
- const requestPath = decodeURIComponent(url.pathname); // Decode URL encoding
1015
- // 2. Apply path rewriting if provided
1016
- const rewrittenPath = staticServe.rewriteRequestPath ? staticServe.rewriteRequestPath(requestPath) : requestPath;
1017
- // 3. Build absolute file path
1018
- const filePath = path.join(staticServe.root, rewrittenPath);
1019
- // 4. Security: Resolve and validate path to prevent traversal attacks
1020
- // Resolve both paths to absolute canonical paths
1021
- const resolvedFilePath = path.resolve(filePath);
1022
- const resolvedRoot = path.resolve(staticServe.root);
1023
- // Check if resolved file path is within root directory
1024
- if (!resolvedFilePath.startsWith(resolvedRoot)) {
1025
- this.logger.warn(`Path traversal attempt detected: ${requestPath} -> ${resolvedFilePath}`);
1026
- return new Response('Forbidden', { status: 403 });
1027
- }
1028
- // 5. Create Bun.file() instance
1029
- const file = Bun.file(resolvedFilePath);
1030
- // 5. Check if file exists
1031
- const fileExists = await file.exists();
1032
- // 6. File not found → trigger onNotFound hook
1033
- if (!fileExists) {
1034
- if (staticServe.onNotFound) {
1035
- await staticServe.onNotFound.handler(rewrittenPath, context);
1036
- // If hook overrides, return null (let handler continue)
1037
- if (staticServe.onNotFound.override) {
1038
- return null;
1039
- }
1040
- }
1041
- // Default 404 response
1042
- return new Response('Not Found', { status: 404 });
1043
- }
1044
- // 7. File found → trigger onFound hook
1045
- if (staticServe.onFound) {
1046
- await staticServe.onFound.handler(rewrittenPath, context);
1092
+ // Deliberately no try/catch. Anything thrown here - a rewriteRequestPath that raises, a
1093
+ // path.resolve or Bun.file failure - travels up to createRouteHandler's catch and through
1094
+ // `respondToError`, like every other failure. Answering it here meant the body was
1095
+ // `{"error": error.message}`, and a message thrown out of the filesystem layer is
1096
+ // specifically a deployment path; and the application's `onError` never saw it at all.
1097
+ // 1. Extract request path from URL
1098
+ const url = new URL(req.url);
1099
+ const requestPath = decodeURIComponent(url.pathname); // Decode URL encoding
1100
+ // 2. Apply path rewriting if provided
1101
+ const rewrittenPath = staticServe.rewriteRequestPath ? staticServe.rewriteRequestPath(requestPath) : requestPath;
1102
+ // 3. Build absolute file path
1103
+ const filePath = path.join(staticServe.root, rewrittenPath);
1104
+ // 4. Security: Resolve and validate path to prevent traversal attacks
1105
+ // Resolve both paths to absolute canonical paths
1106
+ const resolvedFilePath = path.resolve(filePath);
1107
+ const resolvedRoot = path.resolve(staticServe.root);
1108
+ // Check if resolved file path is within root directory
1109
+ if (!resolvedFilePath.startsWith(resolvedRoot)) {
1110
+ this.logger.warn(`Path traversal attempt detected: ${requestPath} -> ${resolvedFilePath}`);
1111
+ return new Response('Forbidden', { status: 403 });
1112
+ }
1113
+ // 5. Create Bun.file() instance
1114
+ const file = Bun.file(resolvedFilePath);
1115
+ // 5. Check if file exists
1116
+ const fileExists = await file.exists();
1117
+ // 6. File not found → trigger onNotFound hook
1118
+ if (!fileExists) {
1119
+ if (staticServe.onNotFound) {
1120
+ await staticServe.onNotFound.handler(rewrittenPath, context);
1047
1121
  // If hook overrides, return null (let handler continue)
1048
- if (staticServe.onFound.override) {
1122
+ if (staticServe.onNotFound.override) {
1049
1123
  return null;
1050
1124
  }
1051
1125
  }
1052
- // 8. Create Bun file response with automatic Content-Type detection
1053
- const fileResponse = new Response(file);
1054
- // 9. Build response headers starting with Bun's Content-Type
1055
- const finalHeaders = new Headers(fileResponse.headers);
1056
- // 10. Add cache validation headers (ETag and Last-Modified)
1057
- // ETag format: W/"<size>-<lastModified>" (weak validator)
1058
- const etag = `W/"${file.size}-${file.lastModified}"`;
1059
- finalHeaders.set('ETag', etag);
1060
- // Last-Modified: HTTP date format (RFC 7231)
1061
- const lastModified = new Date(file.lastModified).toUTCString();
1062
- finalHeaders.set('Last-Modified', lastModified);
1063
- // 11. Add custom headers (can override defaults)
1064
- const customHeaders = this.buildStaticFileHeaders(resolvedFilePath, staticServe.extra);
1065
- for (const [key, value] of Object.entries(customHeaders)) {
1066
- finalHeaders.set(key, value);
1067
- }
1068
- // 12. Add default Cache-Control if not provided
1069
- // Using 'public, max-age=0' allows caching but requires revalidation
1070
- if (!finalHeaders.has('Cache-Control')) {
1071
- finalHeaders.set('Cache-Control', 'public, max-age=0');
1126
+ // No hook, or a hook that did not take over: fall through to the application's
1127
+ // onNotFound, exactly as an unmatched route does. This used to answer a hard-coded
1128
+ // `text/plain` 404 and never consult the config hook - so the same app produced a
1129
+ // different 404 body here than on the hono adapter, where serveStatic calls next()
1130
+ // and the request lands in app.notFound.
1131
+ return await this.respondToUnmatched(context, new URL(req.url).pathname, req.method);
1132
+ }
1133
+ // 7. File found → trigger onFound hook
1134
+ if (staticServe.onFound) {
1135
+ await staticServe.onFound.handler(rewrittenPath, context);
1136
+ // If hook overrides, return null (let handler continue)
1137
+ if (staticServe.onFound.override) {
1138
+ return null;
1072
1139
  }
1073
- // 13. Return response with all headers
1074
- // This preserves Bun's zero-copy file serving while adding cache headers
1075
- return new Response(fileResponse.body, {
1076
- status: fileResponse.status,
1077
- headers: finalHeaders,
1078
- });
1079
1140
  }
1080
- catch (error) {
1081
- this.logger.error('Static file serving error:', error);
1082
- return new Response(JSON.stringify({
1083
- error: error instanceof Error ? error.message : 'Static file serving failed',
1084
- }), {
1085
- status: 500,
1086
- headers: { 'Content-Type': 'application/json' },
1087
- });
1141
+ // 8. Create Bun file response with automatic Content-Type detection
1142
+ const fileResponse = new Response(file);
1143
+ // 9. Build response headers starting with Bun's Content-Type
1144
+ const finalHeaders = new Headers(fileResponse.headers);
1145
+ // 10. Add cache validation headers (ETag and Last-Modified)
1146
+ // ETag format: W/"<size>-<lastModified>" (weak validator)
1147
+ const etag = `W/"${file.size}-${file.lastModified}"`;
1148
+ finalHeaders.set('ETag', etag);
1149
+ // Last-Modified: HTTP date format (RFC 7231)
1150
+ const lastModified = new Date(file.lastModified).toUTCString();
1151
+ finalHeaders.set('Last-Modified', lastModified);
1152
+ // 11. Add custom headers (can override defaults)
1153
+ const customHeaders = this.buildStaticFileHeaders(resolvedFilePath, staticServe.extra);
1154
+ for (const [key, value] of Object.entries(customHeaders)) {
1155
+ finalHeaders.set(key, value);
1088
1156
  }
1157
+ // 12. Add default Cache-Control if not provided
1158
+ // Using 'public, max-age=0' allows caching but requires revalidation
1159
+ if (!finalHeaders.has('Cache-Control')) {
1160
+ finalHeaders.set('Cache-Control', 'public, max-age=0');
1161
+ }
1162
+ // 13. Return response with all headers
1163
+ // This preserves Bun's zero-copy file serving while adding cache headers
1164
+ return new Response(fileResponse.body, {
1165
+ status: fileResponse.status,
1166
+ headers: finalHeaders,
1167
+ });
1089
1168
  }
1090
1169
  /**
1091
1170
  * Builds response headers for static file serving
@@ -1135,108 +1214,6 @@ export class Ergenecore extends AsenaAdapter {
1135
1214
  }
1136
1215
  return headers;
1137
1216
  }
1138
- /**
1139
- * Extracts base path from a route path
1140
- *
1141
- * Removes dynamic segments (parameters and wildcards) from the path
1142
- * to find the static base path for grouping routes.
1143
- *
1144
- * @param path - Route path (e.g., "/api/users/:id")
1145
- * @returns Base path without dynamic segments (e.g., "/api/users")
1146
- *
1147
- * @example
1148
- * ```typescript
1149
- * extractBasePath('/api/users/:id') // => '/api/users'
1150
- * extractBasePath('/api/users/:id/posts/:postId') // => '/api/users'
1151
- * extractBasePath('/static/*') // => '/static'
1152
- * extractBasePath('/') // => '/'
1153
- * ```
1154
- */
1155
- extractBasePath(path) {
1156
- // Remove trailing slash (except for root)
1157
- const normalized = path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path;
1158
- // Split path into segments
1159
- const segments = normalized.split('/');
1160
- const baseSegments = [];
1161
- // Keep segments until we hit a parameter or wildcard
1162
- for (const segment of segments) {
1163
- // Stop if segment is a parameter (:param) or wildcard (*)
1164
- if (segment.startsWith(':') || segment.includes('*')) {
1165
- break;
1166
- }
1167
- baseSegments.push(segment);
1168
- }
1169
- // Join back into path, handle root case
1170
- return baseSegments.join('/') || '/';
1171
- }
1172
- /**
1173
- * Extracts common middlewares across multiple routes
1174
- *
1175
- * Identifies middlewares that are present in ALL routes and can be
1176
- * moved to group level for optimization.
1177
- *
1178
- * @param routes - Array of routes to analyze
1179
- * @returns Array of common middleware instances
1180
- *
1181
- * @example
1182
- * ```typescript
1183
- * extractCommonMiddlewares([
1184
- * { middlewares: [auth, log, rate] },
1185
- * { middlewares: [auth, log] },
1186
- * { middlewares: [auth, log] }
1187
- * ]) // => [auth, log]
1188
- * ```
1189
- */
1190
- extractCommonMiddlewares(routes) {
1191
- // Need at least 2 routes to have common middlewares
1192
- if (routes.length === 0 || routes.length === 1) {
1193
- return [];
1194
- }
1195
- // Get middlewares from first route as baseline
1196
- const firstRouteMiddlewares = routes[0].middlewares || [];
1197
- // Filter to only middlewares present in ALL routes
1198
- return firstRouteMiddlewares.filter((middleware) => {
1199
- return routes.every((route) => {
1200
- return (route.middlewares || []).some((mw) => {
1201
- // Compare by constructor name (class identity)
1202
- return mw.constructor.name === middleware.constructor.name;
1203
- });
1204
- });
1205
- });
1206
- }
1207
- /**
1208
- * Groups routes by their base path
1209
- *
1210
- * Creates a map of base paths to routes for optimization.
1211
- * Routes with the same base path will share common middlewares.
1212
- *
1213
- * @param routes - Array of routes to group
1214
- * @returns Map of base paths to route arrays
1215
- *
1216
- * @example
1217
- * ```typescript
1218
- * groupRoutesByBasePath([
1219
- * { path: '/api/users' },
1220
- * { path: '/api/users/:id' },
1221
- * { path: '/api/posts' }
1222
- * ])
1223
- * // => Map {
1224
- * // '/api/users' => [route1, route2],
1225
- * // '/api/posts' => [route3]
1226
- * // }
1227
- * ```
1228
- */
1229
- groupRoutesByBasePath(routes) {
1230
- const groups = new Map();
1231
- for (const route of routes) {
1232
- const basePath = this.extractBasePath(route.path);
1233
- if (!groups.has(basePath)) {
1234
- groups.set(basePath, []);
1235
- }
1236
- groups.get(basePath).push(route);
1237
- }
1238
- return groups;
1239
- }
1240
1217
  get hostname() {
1241
1218
  return this._hostname;
1242
1219
  }