@onebun/core 0.1.24 → 0.2.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.
@@ -47,6 +47,7 @@ import {
47
47
  type ApplicationOptions,
48
48
  type HttpMethod,
49
49
  type ModuleInstance,
50
+ type OneBunRequest,
50
51
  type ParamMetadata,
51
52
  ParamType,
52
53
  type RouteMetadata,
@@ -113,6 +114,41 @@ function normalizePath(path: string): string {
113
114
  return path.endsWith('/') ? path.slice(0, -1) : path;
114
115
  }
115
116
 
117
+ /**
118
+ * Extract query parameters from URL, handling array notation and repeated keys.
119
+ *
120
+ * @param url - The URL to extract query parameters from
121
+ * @returns Record of parameter names to values (string for single, string[] for repeated/array notation)
122
+ * @example
123
+ * extractQueryParams(new URL('http://x.com/?a=1&b=2')) // { a: '1', b: '2' }
124
+ * extractQueryParams(new URL('http://x.com/?tag=a&tag=b')) // { tag: ['a', 'b'] }
125
+ * extractQueryParams(new URL('http://x.com/?tag[]=a')) // { tag: ['a'] }
126
+ */
127
+ function extractQueryParams(url: URL): Record<string, string | string[]> {
128
+ const queryParams: Record<string, string | string[]> = {};
129
+
130
+ for (const [rawKey, value] of url.searchParams.entries()) {
131
+ // Handle array notation: tag[] -> tag (as array)
132
+ const isArrayNotation = rawKey.endsWith('[]');
133
+ const key = isArrayNotation ? rawKey.replace('[]', '') : rawKey;
134
+
135
+ const existing = queryParams[key];
136
+ if (existing !== undefined) {
137
+ // Handle multiple values with same key (e.g., ?tag=a&tag=b or ?tag[]=a&tag[]=b)
138
+ queryParams[key] = Array.isArray(existing)
139
+ ? [...existing, value]
140
+ : [existing, value];
141
+ } else if (isArrayNotation) {
142
+ // Array notation always creates an array, even with single value
143
+ queryParams[key] = [value];
144
+ } else {
145
+ queryParams[key] = value;
146
+ }
147
+ }
148
+
149
+ return queryParams;
150
+ }
151
+
116
152
  /**
117
153
  * Resolve port from options, environment variable, or default.
118
154
  * Priority: explicit option > PORT env > default (3000)
@@ -431,163 +467,34 @@ export class OneBunApplication {
431
467
  // Initialize Docs (OpenAPI/Swagger) if enabled and available
432
468
  await this.initializeDocs(controllers);
433
469
 
434
- // Create a map of routes with metadata
435
- const routes = new Map<
436
- string,
437
- {
438
- method: string;
439
- handler: Function;
440
- handlerName: string;
441
- controller: Controller;
442
- params?: ParamMetadata[];
443
- middleware?: Function[];
444
- pathPattern?: RegExp;
445
- pathParams?: string[];
446
- responseSchemas?: RouteMetadata['responseSchemas'];
447
- }
448
- >();
449
-
450
- // Build application-level path prefix from options
451
- const appPrefix = this.buildPathPrefix();
452
-
453
- // Add routes from controllers
454
- for (const controllerClass of controllers) {
455
- const controllerMetadata = getControllerMetadata(controllerClass);
456
- if (!controllerMetadata) {
457
- this.logger.warn(`No metadata found for controller: ${controllerClass.name}`);
458
- continue;
459
- }
460
-
461
- // Get controller instance from module
462
- if (!this.ensureModule().getControllerInstance) {
463
- this.logger.warn(
464
- `Module does not support getControllerInstance for ${controllerClass.name}`,
465
- );
466
- continue;
467
- }
468
-
469
- const controller = this.ensureModule().getControllerInstance!(controllerClass) as Controller;
470
- if (!controller) {
471
- this.logger.warn(`Controller instance not found for ${controllerClass.name}`);
472
- continue;
473
- }
474
-
475
- const controllerPath = controllerMetadata.path;
476
-
477
- for (const route of controllerMetadata.routes) {
478
- // Combine: appPrefix + controllerPath + routePath
479
- // Normalize to ensure consistent matching (e.g., '/api/users/' -> '/api/users')
480
- const fullPath = normalizePath(`${appPrefix}${controllerPath}${route.path}`);
481
- const method = this.mapHttpMethod(route.method);
482
- const handler = (controller as unknown as Record<string, Function>)[route.handler].bind(
483
- controller,
484
- );
485
-
486
- // Process path parameters
487
- const pathParams: string[] = [];
488
- let pathPattern: RegExp | undefined;
489
-
490
- // Check if path contains parameters like :id
491
- if (fullPath.includes(':')) {
492
- // Convert path to regex pattern
493
- const pattern = fullPath.replace(/:([^/]+)/g, (_, paramName) => {
494
- pathParams.push(paramName);
495
-
496
- return '([^/]+)';
497
- });
498
- pathPattern = new RegExp(`^${pattern}$`);
499
- }
500
-
501
- // Use method and path as key to avoid conflicts between different HTTP methods
502
- const _routeKey = `${method}:${fullPath}`;
503
- routes.set(_routeKey, {
504
- method,
505
- handler,
506
- handlerName: route.handler,
507
- controller,
508
- params: route.params,
509
- middleware: route.middleware,
510
- pathPattern,
511
- pathParams,
512
- responseSchemas: route.responseSchemas,
513
- });
514
- }
515
- }
516
-
517
- // Log all routes
518
- for (const controllerClass of controllers) {
519
- const metadata = getControllerMetadata(controllerClass);
520
- if (!metadata) {
521
- continue;
522
- }
523
-
524
- for (const route of metadata.routes) {
525
- const fullPath = normalizePath(`${appPrefix}${metadata.path}${route.path}`);
526
- const method = this.mapHttpMethod(route.method);
527
- this.logger.info(`Mapped {${method}} route: ${fullPath}`);
528
- }
529
- }
530
-
531
- // Call onApplicationInit lifecycle hook for all services and controllers
532
- if (this.ensureModule().callOnApplicationInit) {
533
- await this.ensureModule().callOnApplicationInit!();
534
- this.logger.debug('Application initialization hooks completed');
535
- }
470
+ // Create server context binding (used by route handlers and executeHandler)
471
+ const app = this;
536
472
 
537
- // Get metrics path
473
+ // Path constants for framework endpoints
538
474
  const metricsPath = this.options.metrics?.path || '/metrics';
539
-
540
- // Get docs paths
541
475
  const docsPath = this.options.docs?.path || '/docs';
542
476
  const openApiPath = this.options.docs?.jsonPath || '/openapi.json';
543
477
 
544
- // Create server with proper context binding
545
- const app = this;
546
- const hasWebSocketGateways = this.wsHandler?.hasGateways() ?? false;
547
-
548
- // Prepare WebSocket handlers if gateways exist
549
- // When no gateways, use no-op handlers (required by Bun.serve)
550
- const wsHandlers = hasWebSocketGateways ? this.wsHandler!.createWebSocketHandlers() : {
551
-
552
- open() { /* no-op */ },
553
-
554
- message() { /* no-op */ },
555
-
556
- close() { /* no-op */ },
557
-
558
- drain() { /* no-op */ },
559
- };
560
-
561
- this.server = Bun.serve<WsClientData>({
562
- port: this.options.port,
563
- hostname: this.options.host,
564
- // WebSocket handlers
565
- websocket: wsHandlers,
566
- async fetch(req, server) {
567
- const url = new URL(req.url);
568
- const rawPath = url.pathname;
569
- // Normalize path to ensure consistent routing and metrics
570
- // (removes trailing slash except for root path)
571
- const path = normalizePath(rawPath);
572
- const method = req.method;
573
- const startTime = Date.now();
574
-
575
- // Handle WebSocket upgrade if gateways exist
576
- if (hasWebSocketGateways && app.wsHandler) {
577
- const upgradeHeader = req.headers.get('upgrade')?.toLowerCase();
578
- const socketioEnabled = app.options.websocket?.socketio?.enabled ?? false;
579
- const socketioPath = app.options.websocket?.socketio?.path ?? '/socket.io';
580
-
581
- const isSocketIoPath = socketioEnabled && path.startsWith(socketioPath);
582
- if (upgradeHeader === 'websocket' || isSocketIoPath) {
583
- const response = await app.wsHandler.handleUpgrade(req, server);
584
- if (response === undefined) {
585
- return undefined; // Successfully upgraded
586
- }
478
+ // Build application-level path prefix from options
479
+ const appPrefix = this.buildPathPrefix();
587
480
 
588
- return response;
589
- }
590
- }
481
+ // Build Bun routes object: { "/path": { GET: handler, POST: handler } }
482
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
483
+ const bunRoutes: Record<string, any> = {};
484
+
485
+ /**
486
+ * Create a route handler with the full OneBun request lifecycle:
487
+ * tracing setup → middleware chain → executeHandler → metrics → tracing end
488
+ */
489
+ function createRouteHandler(
490
+ routeMeta: RouteMetadata,
491
+ boundHandler: Function,
492
+ controller: Controller,
493
+ fullPath: string,
494
+ method: string,
495
+ ): (req: OneBunRequest) => Promise<Response> {
496
+ return async (req) => {
497
+ const startTime = Date.now();
591
498
 
592
499
  // Setup tracing context if available and enabled
593
500
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -626,7 +533,7 @@ export class OneBunApplication {
626
533
  const httpData = {
627
534
  method,
628
535
  url: req.url,
629
- route: path,
536
+ route: fullPath,
630
537
  userAgent: headers['user-agent'],
631
538
  remoteAddr: headers['x-forwarded-for'] || headers['x-real-ip'],
632
539
  requestSize: headers['content-length']
@@ -648,9 +555,6 @@ export class OneBunApplication {
648
555
  (globalThis as Record<string, unknown>).__onebunCurrentTraceContext =
649
556
  globalCurrentTraceContext;
650
557
  }
651
-
652
- // Propagate trace context to logger
653
- // Note: FiberRef.set should be used within Effect context
654
558
  } catch (error) {
655
559
  app.logger.error(
656
560
  'Failed to setup tracing:',
@@ -659,232 +563,89 @@ export class OneBunApplication {
659
563
  }
660
564
  }
661
565
 
662
- // Handle docs endpoints (OpenAPI/Swagger)
663
- if (app.options.docs?.enabled !== false && app.openApiSpec) {
664
- // Serve Swagger UI HTML
665
- if (path === docsPath && method === 'GET' && app.swaggerHtml) {
666
- return new Response(app.swaggerHtml, {
667
- headers: {
668
- // eslint-disable-next-line @typescript-eslint/naming-convention
669
- 'Content-Type': 'text/html; charset=utf-8',
670
- },
671
- });
672
- }
673
-
674
- // Serve OpenAPI JSON spec
675
- if (path === openApiPath && method === 'GET') {
676
- return new Response(JSON.stringify(app.openApiSpec, null, 2), {
677
- headers: {
678
- // eslint-disable-next-line @typescript-eslint/naming-convention
679
- 'Content-Type': 'application/json',
680
- },
681
- });
682
- }
683
- }
566
+ // Extract query parameters from URL
567
+ const url = new URL(req.url);
568
+ const queryParams = extractQueryParams(url);
684
569
 
685
- // Handle metrics endpoint
686
- if (path === metricsPath && method === 'GET' && app.metricsService) {
687
- try {
688
- const metrics = await app.metricsService.getMetrics();
570
+ try {
571
+ let response: Response;
689
572
 
690
- return new Response(metrics, {
691
- headers: {
692
- // eslint-disable-next-line @typescript-eslint/naming-convention
693
- 'Content-Type': app.metricsService.getContentType(),
694
- },
695
- });
696
- } catch (error) {
697
- app.logger.error(
698
- 'Failed to get metrics:',
699
- error instanceof Error ? error : new Error(String(error)),
700
- );
573
+ // Execute middleware chain if any, then handler
574
+ if (routeMeta.middleware && routeMeta.middleware.length > 0) {
575
+ const next = async (index: number): Promise<Response> => {
576
+ if (index >= routeMeta.middleware!.length) {
577
+ return await executeHandler(
578
+ {
579
+ handler: boundHandler,
580
+ handlerName: routeMeta.handler,
581
+ controller,
582
+ params: routeMeta.params,
583
+ responseSchemas: routeMeta.responseSchemas,
584
+ },
585
+ req,
586
+ queryParams,
587
+ );
588
+ }
701
589
 
702
- return new Response('Internal Server Error', {
703
- status: HttpStatusCode.INTERNAL_SERVER_ERROR,
704
- });
705
- }
706
- }
590
+ const middleware = routeMeta.middleware![index];
707
591
 
708
- // Find exact match first using method and path
709
- const exactRouteKey = `${method}:${path}`;
710
- let route = routes.get(exactRouteKey);
711
- const paramValues: Record<string, string | string[]> = {};
592
+ return await middleware(req, () => next(index + 1));
593
+ };
712
594
 
713
- // Extract query parameters from URL
714
- for (const [rawKey, value] of url.searchParams.entries()) {
715
- // Handle array notation: tag[] -> tag (as array)
716
- const isArrayNotation = rawKey.endsWith('[]');
717
- const key = isArrayNotation ? rawKey.replace('[]', '') : rawKey;
718
-
719
- const existing = paramValues[key];
720
- if (existing !== undefined) {
721
- // Handle multiple values with same key (e.g., ?tag=a&tag=b or ?tag[]=a&tag[]=b)
722
- paramValues[key] = Array.isArray(existing)
723
- ? [...existing, value]
724
- : [existing, value];
725
- } else if (isArrayNotation) {
726
- // Array notation always creates an array, even with single value
727
- paramValues[key] = [value];
595
+ response = await next(0);
728
596
  } else {
729
- paramValues[key] = value;
730
- }
731
- }
732
-
733
- // If no exact match, try pattern matching
734
- if (!route) {
735
- for (const [_routeKey, routeData] of routes) {
736
- // Check if this route matches the method and has a pattern
737
- if (routeData.pathPattern && routeData.method === method) {
738
- const match = path.match(routeData.pathPattern);
739
- if (match) {
740
- route = routeData;
741
- // Extract parameter values
742
- for (let i = 0; i < routeData.pathParams!.length; i++) {
743
- paramValues[routeData.pathParams![i]] = match[i + 1];
744
- }
745
- break;
746
- }
747
- }
597
+ response = await executeHandler(
598
+ {
599
+ handler: boundHandler,
600
+ handlerName: routeMeta.handler,
601
+ controller,
602
+ params: routeMeta.params,
603
+ responseSchemas: routeMeta.responseSchemas,
604
+ },
605
+ req,
606
+ queryParams,
607
+ );
748
608
  }
749
- }
750
609
 
751
- if (!route) {
752
- const response = new Response('Not Found', {
753
- status: HttpStatusCode.NOT_FOUND,
754
- });
755
610
  const duration = Date.now() - startTime;
756
611
 
757
- // Record metrics for 404
612
+ // Record metrics
758
613
  if (app.metricsService && app.metricsService.recordHttpRequest) {
759
614
  const durationSeconds = duration / 1000;
760
615
  app.metricsService.recordHttpRequest({
761
616
  method,
762
- route: path,
763
- statusCode: HttpStatusCode.NOT_FOUND,
617
+ route: fullPath,
618
+ statusCode: response?.status || HttpStatusCode.OK,
764
619
  duration: durationSeconds,
765
- controller: 'unknown',
620
+ controller: controller.constructor.name,
766
621
  action: 'unknown',
767
622
  });
768
623
  }
769
624
 
770
- // End trace for 404
625
+ // End trace
771
626
  if (traceSpan && app.traceService) {
772
627
  try {
773
628
  await Effect.runPromise(
774
629
  app.traceService.endHttpTrace(traceSpan, {
775
- statusCode: HttpStatusCode.NOT_FOUND,
630
+ statusCode: response?.status || HttpStatusCode.OK,
631
+ responseSize: response?.headers?.get('content-length')
632
+ ? parseInt(response.headers.get('content-length')!, 10)
633
+ : undefined,
776
634
  duration,
777
635
  }),
778
636
  );
779
637
  } catch (traceError) {
780
638
  app.logger.error(
781
- 'Failed to end trace for 404:',
639
+ 'Failed to end trace:',
782
640
  traceError instanceof Error ? traceError : new Error(String(traceError)),
783
641
  );
784
642
  }
785
643
  }
786
644
 
787
- // Clear trace context after 404
645
+ // Clear trace context after request
788
646
  clearGlobalTraceContext();
789
647
 
790
648
  return response;
791
- }
792
-
793
- try {
794
- // Execute middleware if any
795
- if (route.middleware && route.middleware.length > 0) {
796
- const next = async (index: number): Promise<Response> => {
797
- if (index >= route!.middleware!.length) {
798
- return await executeHandler(route!, req, paramValues);
799
- }
800
-
801
- const middleware = route!.middleware![index];
802
-
803
- return await middleware(req, () => next(index + 1));
804
- };
805
-
806
- const response = await next(0);
807
- const duration = Date.now() - startTime;
808
-
809
- // Record metrics
810
- if (app.metricsService && app.metricsService.recordHttpRequest) {
811
- const durationSeconds = duration / 1000;
812
- app.metricsService.recordHttpRequest({
813
- method,
814
- route: path,
815
- statusCode: response?.status || HttpStatusCode.OK,
816
- duration: durationSeconds,
817
- controller: route.controller.constructor.name,
818
- action: 'unknown',
819
- });
820
- }
821
-
822
- // End trace
823
- if (traceSpan && app.traceService) {
824
- try {
825
- await Effect.runPromise(
826
- app.traceService.endHttpTrace(traceSpan, {
827
- statusCode: response?.status || HttpStatusCode.OK,
828
- responseSize: response?.headers?.get('content-length')
829
- ? parseInt(response.headers.get('content-length')!, 10)
830
- : undefined,
831
- duration,
832
- }),
833
- );
834
- } catch (traceError) {
835
- app.logger.error(
836
- 'Failed to end trace:',
837
- traceError instanceof Error ? traceError : new Error(String(traceError)),
838
- );
839
- }
840
- }
841
-
842
- // Clear trace context after request
843
- clearGlobalTraceContext();
844
-
845
- return response;
846
- } else {
847
- const response = await executeHandler(route, req, paramValues);
848
- const duration = Date.now() - startTime;
849
-
850
- // Record metrics
851
- if (app.metricsService && app.metricsService.recordHttpRequest) {
852
- const durationSeconds = duration / 1000;
853
- app.metricsService.recordHttpRequest({
854
- method,
855
- route: path,
856
- statusCode: response?.status || HttpStatusCode.OK,
857
- duration: durationSeconds,
858
- controller: route.controller.constructor.name,
859
- action: 'unknown',
860
- });
861
- }
862
-
863
- // End trace
864
- if (traceSpan && app.traceService) {
865
- try {
866
- await Effect.runPromise(
867
- app.traceService.endHttpTrace(traceSpan, {
868
- statusCode: response?.status || HttpStatusCode.OK,
869
- responseSize: response?.headers?.get('content-length')
870
- ? parseInt(response.headers.get('content-length')!, 10)
871
- : undefined,
872
- duration,
873
- }),
874
- );
875
- } catch (traceError) {
876
- app.logger.error(
877
- 'Failed to end trace:',
878
- traceError instanceof Error ? traceError : new Error(String(traceError)),
879
- );
880
- }
881
- }
882
-
883
- // Clear trace context after request
884
- clearGlobalTraceContext();
885
-
886
- return response;
887
- }
888
649
  } catch (error) {
889
650
  app.logger.error(
890
651
  'Request handling error:',
@@ -900,10 +661,10 @@ export class OneBunApplication {
900
661
  const durationSeconds = duration / 1000;
901
662
  app.metricsService.recordHttpRequest({
902
663
  method,
903
- route: path,
664
+ route: fullPath,
904
665
  statusCode: HttpStatusCode.INTERNAL_SERVER_ERROR,
905
666
  duration: durationSeconds,
906
- controller: route?.controller.constructor.name || 'unknown',
667
+ controller: controller.constructor.name,
907
668
  action: 'unknown',
908
669
  });
909
670
  }
@@ -936,6 +697,179 @@ export class OneBunApplication {
936
697
 
937
698
  return response;
938
699
  }
700
+ };
701
+ }
702
+
703
+ // Add routes from controllers
704
+ for (const controllerClass of controllers) {
705
+ const controllerMetadata = getControllerMetadata(controllerClass);
706
+ if (!controllerMetadata) {
707
+ this.logger.warn(`No metadata found for controller: ${controllerClass.name}`);
708
+ continue;
709
+ }
710
+
711
+ // Get controller instance from module
712
+ if (!this.ensureModule().getControllerInstance) {
713
+ this.logger.warn(
714
+ `Module does not support getControllerInstance for ${controllerClass.name}`,
715
+ );
716
+ continue;
717
+ }
718
+
719
+ const controller = this.ensureModule().getControllerInstance!(controllerClass) as Controller;
720
+ if (!controller) {
721
+ this.logger.warn(`Controller instance not found for ${controllerClass.name}`);
722
+ continue;
723
+ }
724
+
725
+ const controllerPath = controllerMetadata.path;
726
+
727
+ for (const route of controllerMetadata.routes) {
728
+ // Combine: appPrefix + controllerPath + routePath
729
+ // Normalize to ensure consistent matching (e.g., '/api/users/' -> '/api/users')
730
+ const fullPath = normalizePath(`${appPrefix}${controllerPath}${route.path}`);
731
+ const method = this.mapHttpMethod(route.method);
732
+ const handler = (controller as unknown as Record<string, Function>)[route.handler].bind(
733
+ controller,
734
+ );
735
+
736
+ // Create wrapped handler with full OneBun lifecycle (tracing, metrics, middleware)
737
+ const wrappedHandler = createRouteHandler(route, handler, controller, fullPath, method);
738
+
739
+ // Add to bunRoutes grouped by path and method
740
+ if (!bunRoutes[fullPath]) {
741
+ bunRoutes[fullPath] = {};
742
+ }
743
+ bunRoutes[fullPath][method] = wrappedHandler;
744
+
745
+ // Register trailing slash variant for consistent matching
746
+ // (e.g., /api/users and /api/users/ both map to the same handler)
747
+ if (fullPath.length > 1 && !fullPath.endsWith('/')) {
748
+ const trailingPath = fullPath + '/';
749
+ if (!bunRoutes[trailingPath]) {
750
+ bunRoutes[trailingPath] = {};
751
+ }
752
+ bunRoutes[trailingPath][method] = wrappedHandler;
753
+ }
754
+ }
755
+ }
756
+
757
+ // Add framework endpoints to routes (docs, metrics)
758
+ if (app.options.docs?.enabled !== false && app.openApiSpec) {
759
+ if (app.swaggerHtml) {
760
+ bunRoutes[docsPath] = {
761
+
762
+ GET: () => new Response(app.swaggerHtml!, {
763
+ headers: {
764
+ // eslint-disable-next-line @typescript-eslint/naming-convention
765
+ 'Content-Type': 'text/html; charset=utf-8',
766
+ },
767
+ }),
768
+ };
769
+ }
770
+ bunRoutes[openApiPath] = {
771
+
772
+ GET: () => new Response(JSON.stringify(app.openApiSpec, null, 2), {
773
+ headers: {
774
+ // eslint-disable-next-line @typescript-eslint/naming-convention
775
+ 'Content-Type': 'application/json',
776
+ },
777
+ }),
778
+ };
779
+ }
780
+
781
+ if (app.metricsService) {
782
+ bunRoutes[metricsPath] = {
783
+
784
+ async GET() {
785
+ try {
786
+ const metrics = await app.metricsService.getMetrics();
787
+
788
+ return new Response(metrics, {
789
+ headers: {
790
+ // eslint-disable-next-line @typescript-eslint/naming-convention
791
+ 'Content-Type': app.metricsService.getContentType(),
792
+ },
793
+ });
794
+ } catch (error) {
795
+ app.logger.error(
796
+ 'Failed to get metrics:',
797
+ error instanceof Error ? error : new Error(String(error)),
798
+ );
799
+
800
+ return new Response('Internal Server Error', {
801
+ status: HttpStatusCode.INTERNAL_SERVER_ERROR,
802
+ });
803
+ }
804
+ },
805
+ };
806
+ }
807
+
808
+ // Log all routes
809
+ for (const controllerClass of controllers) {
810
+ const metadata = getControllerMetadata(controllerClass);
811
+ if (!metadata) {
812
+ continue;
813
+ }
814
+
815
+ for (const route of metadata.routes) {
816
+ const fullPath = normalizePath(`${appPrefix}${metadata.path}${route.path}`);
817
+ const method = this.mapHttpMethod(route.method);
818
+ this.logger.info(`Mapped {${method}} route: ${fullPath}`);
819
+ }
820
+ }
821
+
822
+ // Call onApplicationInit lifecycle hook for all services and controllers
823
+ if (this.ensureModule().callOnApplicationInit) {
824
+ await this.ensureModule().callOnApplicationInit!();
825
+ this.logger.debug('Application initialization hooks completed');
826
+ }
827
+
828
+ const hasWebSocketGateways = this.wsHandler?.hasGateways() ?? false;
829
+
830
+ // Prepare WebSocket handlers if gateways exist
831
+ // When no gateways, use no-op handlers (required by Bun.serve)
832
+ const wsHandlers = hasWebSocketGateways ? this.wsHandler!.createWebSocketHandlers() : {
833
+
834
+ open() { /* no-op */ },
835
+
836
+ message() { /* no-op */ },
837
+
838
+ close() { /* no-op */ },
839
+
840
+ drain() { /* no-op */ },
841
+ };
842
+
843
+ this.server = Bun.serve<WsClientData>({
844
+ port: this.options.port,
845
+ hostname: this.options.host,
846
+ // WebSocket handlers
847
+ websocket: wsHandlers,
848
+ // Bun routes API: all endpoints are handled here
849
+ routes: bunRoutes,
850
+ // Fallback: only WebSocket upgrade and 404
851
+ async fetch(req, server) {
852
+ // Handle WebSocket upgrade if gateways exist
853
+ if (hasWebSocketGateways && app.wsHandler) {
854
+ const upgradeHeader = req.headers.get('upgrade')?.toLowerCase();
855
+ const socketioEnabled = app.options.websocket?.socketio?.enabled ?? false;
856
+ const socketioPath = app.options.websocket?.socketio?.path ?? '/socket.io';
857
+
858
+ const url = new URL(req.url);
859
+ const path = normalizePath(url.pathname);
860
+ const isSocketIoPath = socketioEnabled && path.startsWith(socketioPath);
861
+ if (upgradeHeader === 'websocket' || isSocketIoPath) {
862
+ const response = await app.wsHandler.handleUpgrade(req, server);
863
+ if (response === undefined) {
864
+ return undefined; // Successfully upgraded
865
+ }
866
+
867
+ return response;
868
+ }
869
+ }
870
+
871
+ // 404 for everything not matched by routes
872
+ return new Response('Not Found', { status: HttpStatusCode.NOT_FOUND });
939
873
  },
940
874
  });
941
875
 
@@ -978,7 +912,9 @@ export class OneBunApplication {
978
912
  }
979
913
 
980
914
  /**
981
- * Execute route handler with parameter injection and validation
915
+ * Execute route handler with parameter injection and validation.
916
+ * Path parameters come from BunRequest.params (populated by Bun routes API).
917
+ * Query parameters are extracted separately from the URL.
982
918
  */
983
919
  async function executeHandler(
984
920
  route: {
@@ -988,8 +924,8 @@ export class OneBunApplication {
988
924
  params?: ParamMetadata[];
989
925
  responseSchemas?: RouteMetadata['responseSchemas'];
990
926
  },
991
- req: Request,
992
- paramValues: Record<string, string | string[]>,
927
+ req: OneBunRequest,
928
+ queryParams: Record<string, string | string[]>,
993
929
  ): Promise<Response> {
994
930
  // Check if this is an SSE endpoint
995
931
  let sseOptions: SseDecoratorOptions | undefined;
@@ -1018,11 +954,14 @@ export class OneBunApplication {
1018
954
  for (const param of sortedParams) {
1019
955
  switch (param.type) {
1020
956
  case ParamType.PATH:
1021
- args[param.index] = param.name ? paramValues[param.name] : undefined;
957
+ // Use req.params from BunRequest (natively populated by Bun routes API)
958
+ args[param.index] = param.name
959
+ ? (req.params as Record<string, string>)[param.name]
960
+ : undefined;
1022
961
  break;
1023
962
 
1024
963
  case ParamType.QUERY:
1025
- args[param.index] = param.name ? paramValues[param.name] : undefined;
964
+ args[param.index] = param.name ? queryParams[param.name] : undefined;
1026
965
  break;
1027
966
 
1028
967
  case ParamType.BODY:
@@ -1037,6 +976,10 @@ export class OneBunApplication {
1037
976
  args[param.index] = param.name ? req.headers.get(param.name) : undefined;
1038
977
  break;
1039
978
 
979
+ case ParamType.COOKIE:
980
+ args[param.index] = param.name ? req.cookies.get(param.name) ?? undefined : undefined;
981
+ break;
982
+
1040
983
  case ParamType.REQUEST:
1041
984
  args[param.index] = req;
1042
985
  break;
@@ -1085,7 +1028,6 @@ export class OneBunApplication {
1085
1028
  // If the result is already a Response object, extract body and validate it
1086
1029
  if (result instanceof Response) {
1087
1030
  responseStatusCode = result.status;
1088
- const responseHeaders = Object.fromEntries(result.headers.entries());
1089
1031
 
1090
1032
  // Extract and parse response body for validation
1091
1033
  const contentType = result.headers.get('content-type') || '';
@@ -1119,14 +1061,16 @@ export class OneBunApplication {
1119
1061
  validatedResult = bodyData;
1120
1062
  }
1121
1063
 
1064
+ // Preserve all original headers (including multiple Set-Cookie)
1065
+ // using new Headers() constructor instead of Object.fromEntries()
1066
+ // which would lose duplicate header keys
1067
+ const newHeaders = new Headers(result.headers);
1068
+ newHeaders.set('Content-Type', 'application/json');
1069
+
1122
1070
  // Create new Response with validated data
1123
1071
  return new Response(JSON.stringify(validatedResult), {
1124
1072
  status: responseStatusCode,
1125
- headers: {
1126
- ...responseHeaders,
1127
- // eslint-disable-next-line @typescript-eslint/naming-convention
1128
- 'Content-Type': 'application/json',
1129
- },
1073
+ headers: newHeaders,
1130
1074
  });
1131
1075
  } catch {
1132
1076
  // If parsing fails, return original response