@onebun/core 0.1.23 → 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)
@@ -152,12 +188,14 @@ function resolveHost(explicitHost: string | undefined): string {
152
188
  * OneBun Application
153
189
  */
154
190
  export class OneBunApplication {
155
- private rootModule: ModuleInstance;
191
+ private rootModule: ModuleInstance | null = null;
156
192
  private server: ReturnType<typeof Bun.serve> | null = null;
157
193
  private options: ApplicationOptions;
158
194
  private logger: SyncLogger;
159
195
  private config: IConfig<OneBunAppConfig>;
160
196
  private configService: ConfigServiceImpl | null = null;
197
+ private moduleClass: new (...args: unknown[]) => object;
198
+ private loggerLayer: Layer.Layer<never, never, unknown>;
161
199
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
162
200
  private metricsService: any = null;
163
201
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -176,6 +214,8 @@ export class OneBunApplication {
176
214
  moduleClass: new (...args: unknown[]) => object,
177
215
  options?: Partial<ApplicationOptions>,
178
216
  ) {
217
+ this.moduleClass = moduleClass;
218
+
179
219
  // Resolve port and host with priority: explicit > env > default
180
220
  this.options = {
181
221
  port: resolvePort(options?.port),
@@ -194,7 +234,7 @@ export class OneBunApplication {
194
234
 
195
235
  // Use provided logger layer, or create from options, or use default
196
236
  // Priority: loggerLayer > loggerOptions > env variables > NODE_ENV defaults
197
- const loggerLayer = this.options.loggerLayer
237
+ this.loggerLayer = this.options.loggerLayer
198
238
  ?? (this.options.loggerOptions
199
239
  ? makeLoggerFromOptions(this.options.loggerOptions)
200
240
  : makeLogger());
@@ -205,13 +245,14 @@ export class OneBunApplication {
205
245
  Effect.map(LoggerService, (logger: Logger) =>
206
246
  logger.child({ className: 'OneBunApplication' }),
207
247
  ),
208
- loggerLayer,
209
- ),
248
+ this.loggerLayer,
249
+ ) as Effect.Effect<Logger, never, never>,
210
250
  ) as Logger;
211
251
  this.logger = createSyncLogger(effectLogger);
212
252
 
213
- // Create configuration service if config is initialized
214
- if (this.config.isInitialized || !(this.config instanceof NotInitializedConfig)) {
253
+ // Create configuration service eagerly if config exists (it stores a reference,
254
+ // doesn't call config.get(), so safe before initialization)
255
+ if (!(this.config instanceof NotInitializedConfig)) {
215
256
  this.configService = new ConfigServiceImpl(this.logger, this.config);
216
257
  }
217
258
 
@@ -274,8 +315,8 @@ export class OneBunApplication {
274
315
  }
275
316
  }
276
317
 
277
- // Create the root module with logger layer and config
278
- this.rootModule = OneBunModule.create(moduleClass, loggerLayer, this.config);
318
+ // Note: root module creation is deferred to start() to ensure
319
+ // config is fully initialized before services are created.
279
320
  }
280
321
 
281
322
  /**
@@ -323,11 +364,23 @@ export class OneBunApplication {
323
364
  return this.getConfig().get(path);
324
365
  }
325
366
 
367
+ /**
368
+ * Ensure root module is created (i.e., start() has been called).
369
+ * Throws if called before start().
370
+ */
371
+ private ensureModule(): ModuleInstance {
372
+ if (!this.rootModule) {
373
+ throw new Error('Application not started. Call start() before accessing the module.');
374
+ }
375
+
376
+ return this.rootModule;
377
+ }
378
+
326
379
  /**
327
380
  * Get root module layer
328
381
  */
329
382
  getLayer(): Layer.Layer<never, never, unknown> {
330
- return this.rootModule.getLayer();
383
+ return this.ensureModule().getLayer();
331
384
  }
332
385
 
333
386
  /**
@@ -377,6 +430,10 @@ export class OneBunApplication {
377
430
  this.logger.info('Application configuration initialized');
378
431
  }
379
432
 
433
+ // Create the root module AFTER config is initialized,
434
+ // so services can safely use this.config.get() in their constructors
435
+ this.rootModule = OneBunModule.create(this.moduleClass, this.loggerLayer, this.config);
436
+
380
437
  // Start metrics collection if enabled
381
438
  if (this.metricsService && this.metricsService.startSystemMetricsCollection) {
382
439
  this.metricsService.startSystemMetricsCollection();
@@ -384,10 +441,10 @@ export class OneBunApplication {
384
441
  }
385
442
 
386
443
  // Setup the module and create controller instances
387
- await Effect.runPromise(this.rootModule.setup() as Effect.Effect<unknown, never, never>);
444
+ await Effect.runPromise(this.ensureModule().setup() as Effect.Effect<unknown, never, never>);
388
445
 
389
446
  // Get all controllers from the root module
390
- const controllers = this.rootModule.getControllers();
447
+ const controllers = this.ensureModule().getControllers();
391
448
  this.logger.debug(`Loaded ${controllers.length} controllers`);
392
449
 
393
450
  // Initialize WebSocket handler and detect gateways
@@ -396,7 +453,7 @@ export class OneBunApplication {
396
453
  // Register WebSocket gateways (they are in controllers array but decorated with @WebSocketGateway)
397
454
  for (const controllerClass of controllers) {
398
455
  if (isWebSocketGateway(controllerClass)) {
399
- const instance = this.rootModule.getControllerInstance?.(controllerClass);
456
+ const instance = this.ensureModule().getControllerInstance?.(controllerClass);
400
457
  if (instance) {
401
458
  this.wsHandler.registerGateway(controllerClass, instance as import('../websocket/ws-base-gateway').BaseWebSocketGateway);
402
459
  this.logger.info(`Registered WebSocket gateway: ${controllerClass.name}`);
@@ -410,163 +467,34 @@ export class OneBunApplication {
410
467
  // Initialize Docs (OpenAPI/Swagger) if enabled and available
411
468
  await this.initializeDocs(controllers);
412
469
 
413
- // Create a map of routes with metadata
414
- const routes = new Map<
415
- string,
416
- {
417
- method: string;
418
- handler: Function;
419
- handlerName: string;
420
- controller: Controller;
421
- params?: ParamMetadata[];
422
- middleware?: Function[];
423
- pathPattern?: RegExp;
424
- pathParams?: string[];
425
- responseSchemas?: RouteMetadata['responseSchemas'];
426
- }
427
- >();
428
-
429
- // Build application-level path prefix from options
430
- const appPrefix = this.buildPathPrefix();
431
-
432
- // Add routes from controllers
433
- for (const controllerClass of controllers) {
434
- const controllerMetadata = getControllerMetadata(controllerClass);
435
- if (!controllerMetadata) {
436
- this.logger.warn(`No metadata found for controller: ${controllerClass.name}`);
437
- continue;
438
- }
439
-
440
- // Get controller instance from module
441
- if (!this.rootModule.getControllerInstance) {
442
- this.logger.warn(
443
- `Module does not support getControllerInstance for ${controllerClass.name}`,
444
- );
445
- continue;
446
- }
447
-
448
- const controller = this.rootModule.getControllerInstance(controllerClass) as Controller;
449
- if (!controller) {
450
- this.logger.warn(`Controller instance not found for ${controllerClass.name}`);
451
- continue;
452
- }
453
-
454
- const controllerPath = controllerMetadata.path;
455
-
456
- for (const route of controllerMetadata.routes) {
457
- // Combine: appPrefix + controllerPath + routePath
458
- // Normalize to ensure consistent matching (e.g., '/api/users/' -> '/api/users')
459
- const fullPath = normalizePath(`${appPrefix}${controllerPath}${route.path}`);
460
- const method = this.mapHttpMethod(route.method);
461
- const handler = (controller as unknown as Record<string, Function>)[route.handler].bind(
462
- controller,
463
- );
464
-
465
- // Process path parameters
466
- const pathParams: string[] = [];
467
- let pathPattern: RegExp | undefined;
468
-
469
- // Check if path contains parameters like :id
470
- if (fullPath.includes(':')) {
471
- // Convert path to regex pattern
472
- const pattern = fullPath.replace(/:([^/]+)/g, (_, paramName) => {
473
- pathParams.push(paramName);
474
-
475
- return '([^/]+)';
476
- });
477
- pathPattern = new RegExp(`^${pattern}$`);
478
- }
479
-
480
- // Use method and path as key to avoid conflicts between different HTTP methods
481
- const _routeKey = `${method}:${fullPath}`;
482
- routes.set(_routeKey, {
483
- method,
484
- handler,
485
- handlerName: route.handler,
486
- controller,
487
- params: route.params,
488
- middleware: route.middleware,
489
- pathPattern,
490
- pathParams,
491
- responseSchemas: route.responseSchemas,
492
- });
493
- }
494
- }
495
-
496
- // Log all routes
497
- for (const controllerClass of controllers) {
498
- const metadata = getControllerMetadata(controllerClass);
499
- if (!metadata) {
500
- continue;
501
- }
502
-
503
- for (const route of metadata.routes) {
504
- const fullPath = normalizePath(`${appPrefix}${metadata.path}${route.path}`);
505
- const method = this.mapHttpMethod(route.method);
506
- this.logger.info(`Mapped {${method}} route: ${fullPath}`);
507
- }
508
- }
509
-
510
- // Call onApplicationInit lifecycle hook for all services and controllers
511
- if (this.rootModule.callOnApplicationInit) {
512
- await this.rootModule.callOnApplicationInit();
513
- this.logger.debug('Application initialization hooks completed');
514
- }
470
+ // Create server context binding (used by route handlers and executeHandler)
471
+ const app = this;
515
472
 
516
- // Get metrics path
473
+ // Path constants for framework endpoints
517
474
  const metricsPath = this.options.metrics?.path || '/metrics';
518
-
519
- // Get docs paths
520
475
  const docsPath = this.options.docs?.path || '/docs';
521
476
  const openApiPath = this.options.docs?.jsonPath || '/openapi.json';
522
477
 
523
- // Create server with proper context binding
524
- const app = this;
525
- const hasWebSocketGateways = this.wsHandler?.hasGateways() ?? false;
526
-
527
- // Prepare WebSocket handlers if gateways exist
528
- // When no gateways, use no-op handlers (required by Bun.serve)
529
- const wsHandlers = hasWebSocketGateways ? this.wsHandler!.createWebSocketHandlers() : {
530
-
531
- open() { /* no-op */ },
532
-
533
- message() { /* no-op */ },
534
-
535
- close() { /* no-op */ },
536
-
537
- drain() { /* no-op */ },
538
- };
539
-
540
- this.server = Bun.serve<WsClientData>({
541
- port: this.options.port,
542
- hostname: this.options.host,
543
- // WebSocket handlers
544
- websocket: wsHandlers,
545
- async fetch(req, server) {
546
- const url = new URL(req.url);
547
- const rawPath = url.pathname;
548
- // Normalize path to ensure consistent routing and metrics
549
- // (removes trailing slash except for root path)
550
- const path = normalizePath(rawPath);
551
- const method = req.method;
552
- const startTime = Date.now();
553
-
554
- // Handle WebSocket upgrade if gateways exist
555
- if (hasWebSocketGateways && app.wsHandler) {
556
- const upgradeHeader = req.headers.get('upgrade')?.toLowerCase();
557
- const socketioEnabled = app.options.websocket?.socketio?.enabled ?? false;
558
- const socketioPath = app.options.websocket?.socketio?.path ?? '/socket.io';
559
-
560
- const isSocketIoPath = socketioEnabled && path.startsWith(socketioPath);
561
- if (upgradeHeader === 'websocket' || isSocketIoPath) {
562
- const response = await app.wsHandler.handleUpgrade(req, server);
563
- if (response === undefined) {
564
- return undefined; // Successfully upgraded
565
- }
478
+ // Build application-level path prefix from options
479
+ const appPrefix = this.buildPathPrefix();
566
480
 
567
- return response;
568
- }
569
- }
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();
570
498
 
571
499
  // Setup tracing context if available and enabled
572
500
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -605,7 +533,7 @@ export class OneBunApplication {
605
533
  const httpData = {
606
534
  method,
607
535
  url: req.url,
608
- route: path,
536
+ route: fullPath,
609
537
  userAgent: headers['user-agent'],
610
538
  remoteAddr: headers['x-forwarded-for'] || headers['x-real-ip'],
611
539
  requestSize: headers['content-length']
@@ -627,9 +555,6 @@ export class OneBunApplication {
627
555
  (globalThis as Record<string, unknown>).__onebunCurrentTraceContext =
628
556
  globalCurrentTraceContext;
629
557
  }
630
-
631
- // Propagate trace context to logger
632
- // Note: FiberRef.set should be used within Effect context
633
558
  } catch (error) {
634
559
  app.logger.error(
635
560
  'Failed to setup tracing:',
@@ -638,232 +563,89 @@ export class OneBunApplication {
638
563
  }
639
564
  }
640
565
 
641
- // Handle docs endpoints (OpenAPI/Swagger)
642
- if (app.options.docs?.enabled !== false && app.openApiSpec) {
643
- // Serve Swagger UI HTML
644
- if (path === docsPath && method === 'GET' && app.swaggerHtml) {
645
- return new Response(app.swaggerHtml, {
646
- headers: {
647
- // eslint-disable-next-line @typescript-eslint/naming-convention
648
- 'Content-Type': 'text/html; charset=utf-8',
649
- },
650
- });
651
- }
652
-
653
- // Serve OpenAPI JSON spec
654
- if (path === openApiPath && method === 'GET') {
655
- return new Response(JSON.stringify(app.openApiSpec, null, 2), {
656
- headers: {
657
- // eslint-disable-next-line @typescript-eslint/naming-convention
658
- 'Content-Type': 'application/json',
659
- },
660
- });
661
- }
662
- }
566
+ // Extract query parameters from URL
567
+ const url = new URL(req.url);
568
+ const queryParams = extractQueryParams(url);
663
569
 
664
- // Handle metrics endpoint
665
- if (path === metricsPath && method === 'GET' && app.metricsService) {
666
- try {
667
- const metrics = await app.metricsService.getMetrics();
570
+ try {
571
+ let response: Response;
668
572
 
669
- return new Response(metrics, {
670
- headers: {
671
- // eslint-disable-next-line @typescript-eslint/naming-convention
672
- 'Content-Type': app.metricsService.getContentType(),
673
- },
674
- });
675
- } catch (error) {
676
- app.logger.error(
677
- 'Failed to get metrics:',
678
- error instanceof Error ? error : new Error(String(error)),
679
- );
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
+ }
680
589
 
681
- return new Response('Internal Server Error', {
682
- status: HttpStatusCode.INTERNAL_SERVER_ERROR,
683
- });
684
- }
685
- }
590
+ const middleware = routeMeta.middleware![index];
686
591
 
687
- // Find exact match first using method and path
688
- const exactRouteKey = `${method}:${path}`;
689
- let route = routes.get(exactRouteKey);
690
- const paramValues: Record<string, string | string[]> = {};
592
+ return await middleware(req, () => next(index + 1));
593
+ };
691
594
 
692
- // Extract query parameters from URL
693
- for (const [rawKey, value] of url.searchParams.entries()) {
694
- // Handle array notation: tag[] -> tag (as array)
695
- const isArrayNotation = rawKey.endsWith('[]');
696
- const key = isArrayNotation ? rawKey.replace('[]', '') : rawKey;
697
-
698
- const existing = paramValues[key];
699
- if (existing !== undefined) {
700
- // Handle multiple values with same key (e.g., ?tag=a&tag=b or ?tag[]=a&tag[]=b)
701
- paramValues[key] = Array.isArray(existing)
702
- ? [...existing, value]
703
- : [existing, value];
704
- } else if (isArrayNotation) {
705
- // Array notation always creates an array, even with single value
706
- paramValues[key] = [value];
595
+ response = await next(0);
707
596
  } else {
708
- paramValues[key] = value;
709
- }
710
- }
711
-
712
- // If no exact match, try pattern matching
713
- if (!route) {
714
- for (const [_routeKey, routeData] of routes) {
715
- // Check if this route matches the method and has a pattern
716
- if (routeData.pathPattern && routeData.method === method) {
717
- const match = path.match(routeData.pathPattern);
718
- if (match) {
719
- route = routeData;
720
- // Extract parameter values
721
- for (let i = 0; i < routeData.pathParams!.length; i++) {
722
- paramValues[routeData.pathParams![i]] = match[i + 1];
723
- }
724
- break;
725
- }
726
- }
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
+ );
727
608
  }
728
- }
729
609
 
730
- if (!route) {
731
- const response = new Response('Not Found', {
732
- status: HttpStatusCode.NOT_FOUND,
733
- });
734
610
  const duration = Date.now() - startTime;
735
611
 
736
- // Record metrics for 404
612
+ // Record metrics
737
613
  if (app.metricsService && app.metricsService.recordHttpRequest) {
738
614
  const durationSeconds = duration / 1000;
739
615
  app.metricsService.recordHttpRequest({
740
616
  method,
741
- route: path,
742
- statusCode: HttpStatusCode.NOT_FOUND,
617
+ route: fullPath,
618
+ statusCode: response?.status || HttpStatusCode.OK,
743
619
  duration: durationSeconds,
744
- controller: 'unknown',
620
+ controller: controller.constructor.name,
745
621
  action: 'unknown',
746
622
  });
747
623
  }
748
624
 
749
- // End trace for 404
625
+ // End trace
750
626
  if (traceSpan && app.traceService) {
751
627
  try {
752
628
  await Effect.runPromise(
753
629
  app.traceService.endHttpTrace(traceSpan, {
754
- 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,
755
634
  duration,
756
635
  }),
757
636
  );
758
637
  } catch (traceError) {
759
638
  app.logger.error(
760
- 'Failed to end trace for 404:',
639
+ 'Failed to end trace:',
761
640
  traceError instanceof Error ? traceError : new Error(String(traceError)),
762
641
  );
763
642
  }
764
643
  }
765
644
 
766
- // Clear trace context after 404
645
+ // Clear trace context after request
767
646
  clearGlobalTraceContext();
768
647
 
769
648
  return response;
770
- }
771
-
772
- try {
773
- // Execute middleware if any
774
- if (route.middleware && route.middleware.length > 0) {
775
- const next = async (index: number): Promise<Response> => {
776
- if (index >= route!.middleware!.length) {
777
- return await executeHandler(route!, req, paramValues);
778
- }
779
-
780
- const middleware = route!.middleware![index];
781
-
782
- return await middleware(req, () => next(index + 1));
783
- };
784
-
785
- const response = await next(0);
786
- const duration = Date.now() - startTime;
787
-
788
- // Record metrics
789
- if (app.metricsService && app.metricsService.recordHttpRequest) {
790
- const durationSeconds = duration / 1000;
791
- app.metricsService.recordHttpRequest({
792
- method,
793
- route: path,
794
- statusCode: response?.status || HttpStatusCode.OK,
795
- duration: durationSeconds,
796
- controller: route.controller.constructor.name,
797
- action: 'unknown',
798
- });
799
- }
800
-
801
- // End trace
802
- if (traceSpan && app.traceService) {
803
- try {
804
- await Effect.runPromise(
805
- app.traceService.endHttpTrace(traceSpan, {
806
- statusCode: response?.status || HttpStatusCode.OK,
807
- responseSize: response?.headers?.get('content-length')
808
- ? parseInt(response.headers.get('content-length')!, 10)
809
- : undefined,
810
- duration,
811
- }),
812
- );
813
- } catch (traceError) {
814
- app.logger.error(
815
- 'Failed to end trace:',
816
- traceError instanceof Error ? traceError : new Error(String(traceError)),
817
- );
818
- }
819
- }
820
-
821
- // Clear trace context after request
822
- clearGlobalTraceContext();
823
-
824
- return response;
825
- } else {
826
- const response = await executeHandler(route, req, paramValues);
827
- const duration = Date.now() - startTime;
828
-
829
- // Record metrics
830
- if (app.metricsService && app.metricsService.recordHttpRequest) {
831
- const durationSeconds = duration / 1000;
832
- app.metricsService.recordHttpRequest({
833
- method,
834
- route: path,
835
- statusCode: response?.status || HttpStatusCode.OK,
836
- duration: durationSeconds,
837
- controller: route.controller.constructor.name,
838
- action: 'unknown',
839
- });
840
- }
841
-
842
- // End trace
843
- if (traceSpan && app.traceService) {
844
- try {
845
- await Effect.runPromise(
846
- app.traceService.endHttpTrace(traceSpan, {
847
- statusCode: response?.status || HttpStatusCode.OK,
848
- responseSize: response?.headers?.get('content-length')
849
- ? parseInt(response.headers.get('content-length')!, 10)
850
- : undefined,
851
- duration,
852
- }),
853
- );
854
- } catch (traceError) {
855
- app.logger.error(
856
- 'Failed to end trace:',
857
- traceError instanceof Error ? traceError : new Error(String(traceError)),
858
- );
859
- }
860
- }
861
-
862
- // Clear trace context after request
863
- clearGlobalTraceContext();
864
-
865
- return response;
866
- }
867
649
  } catch (error) {
868
650
  app.logger.error(
869
651
  'Request handling error:',
@@ -879,10 +661,10 @@ export class OneBunApplication {
879
661
  const durationSeconds = duration / 1000;
880
662
  app.metricsService.recordHttpRequest({
881
663
  method,
882
- route: path,
664
+ route: fullPath,
883
665
  statusCode: HttpStatusCode.INTERNAL_SERVER_ERROR,
884
666
  duration: durationSeconds,
885
- controller: route?.controller.constructor.name || 'unknown',
667
+ controller: controller.constructor.name,
886
668
  action: 'unknown',
887
669
  });
888
670
  }
@@ -915,6 +697,179 @@ export class OneBunApplication {
915
697
 
916
698
  return response;
917
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 });
918
873
  },
919
874
  });
920
875
 
@@ -957,7 +912,9 @@ export class OneBunApplication {
957
912
  }
958
913
 
959
914
  /**
960
- * 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.
961
918
  */
962
919
  async function executeHandler(
963
920
  route: {
@@ -967,8 +924,8 @@ export class OneBunApplication {
967
924
  params?: ParamMetadata[];
968
925
  responseSchemas?: RouteMetadata['responseSchemas'];
969
926
  },
970
- req: Request,
971
- paramValues: Record<string, string | string[]>,
927
+ req: OneBunRequest,
928
+ queryParams: Record<string, string | string[]>,
972
929
  ): Promise<Response> {
973
930
  // Check if this is an SSE endpoint
974
931
  let sseOptions: SseDecoratorOptions | undefined;
@@ -997,11 +954,14 @@ export class OneBunApplication {
997
954
  for (const param of sortedParams) {
998
955
  switch (param.type) {
999
956
  case ParamType.PATH:
1000
- 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;
1001
961
  break;
1002
962
 
1003
963
  case ParamType.QUERY:
1004
- args[param.index] = param.name ? paramValues[param.name] : undefined;
964
+ args[param.index] = param.name ? queryParams[param.name] : undefined;
1005
965
  break;
1006
966
 
1007
967
  case ParamType.BODY:
@@ -1016,6 +976,10 @@ export class OneBunApplication {
1016
976
  args[param.index] = param.name ? req.headers.get(param.name) : undefined;
1017
977
  break;
1018
978
 
979
+ case ParamType.COOKIE:
980
+ args[param.index] = param.name ? req.cookies.get(param.name) ?? undefined : undefined;
981
+ break;
982
+
1019
983
  case ParamType.REQUEST:
1020
984
  args[param.index] = req;
1021
985
  break;
@@ -1064,7 +1028,6 @@ export class OneBunApplication {
1064
1028
  // If the result is already a Response object, extract body and validate it
1065
1029
  if (result instanceof Response) {
1066
1030
  responseStatusCode = result.status;
1067
- const responseHeaders = Object.fromEntries(result.headers.entries());
1068
1031
 
1069
1032
  // Extract and parse response body for validation
1070
1033
  const contentType = result.headers.get('content-type') || '';
@@ -1098,14 +1061,16 @@ export class OneBunApplication {
1098
1061
  validatedResult = bodyData;
1099
1062
  }
1100
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
+
1101
1070
  // Create new Response with validated data
1102
1071
  return new Response(JSON.stringify(validatedResult), {
1103
1072
  status: responseStatusCode,
1104
- headers: {
1105
- ...responseHeaders,
1106
- // eslint-disable-next-line @typescript-eslint/naming-convention
1107
- 'Content-Type': 'application/json',
1108
- },
1073
+ headers: newHeaders,
1109
1074
  });
1110
1075
  } catch {
1111
1076
  // If parsing fails, return original response
@@ -1242,7 +1207,7 @@ export class OneBunApplication {
1242
1207
  this.logger.info('Stopping OneBun application...');
1243
1208
 
1244
1209
  // Call beforeApplicationDestroy lifecycle hook
1245
- if (this.rootModule.callBeforeApplicationDestroy) {
1210
+ if (this.rootModule?.callBeforeApplicationDestroy) {
1246
1211
  this.logger.debug('Calling beforeApplicationDestroy hooks');
1247
1212
  await this.rootModule.callBeforeApplicationDestroy(signal);
1248
1213
  }
@@ -1276,7 +1241,7 @@ export class OneBunApplication {
1276
1241
  }
1277
1242
 
1278
1243
  // Call onModuleDestroy lifecycle hook
1279
- if (this.rootModule.callOnModuleDestroy) {
1244
+ if (this.rootModule?.callOnModuleDestroy) {
1280
1245
  this.logger.debug('Calling onModuleDestroy hooks');
1281
1246
  await this.rootModule.callOnModuleDestroy();
1282
1247
  }
@@ -1288,7 +1253,7 @@ export class OneBunApplication {
1288
1253
  }
1289
1254
 
1290
1255
  // Call onApplicationDestroy lifecycle hook
1291
- if (this.rootModule.callOnApplicationDestroy) {
1256
+ if (this.rootModule?.callOnApplicationDestroy) {
1292
1257
  this.logger.debug('Calling onApplicationDestroy hooks');
1293
1258
  await this.rootModule.callOnApplicationDestroy(signal);
1294
1259
  }
@@ -1304,7 +1269,7 @@ export class OneBunApplication {
1304
1269
 
1305
1270
  // Check if any controller has queue-related decorators
1306
1271
  const hasQueueHandlers = controllers.some(controller => {
1307
- const instance = this.rootModule.getControllerInstance?.(controller);
1272
+ const instance = this.ensureModule().getControllerInstance?.(controller);
1308
1273
  if (!instance) {
1309
1274
  return false;
1310
1275
  }
@@ -1364,7 +1329,7 @@ export class OneBunApplication {
1364
1329
 
1365
1330
  // Register handlers from controllers using registerService
1366
1331
  for (const controllerClass of controllers) {
1367
- const instance = this.rootModule.getControllerInstance?.(controllerClass);
1332
+ const instance = this.ensureModule().getControllerInstance?.(controllerClass);
1368
1333
  if (!instance) {
1369
1334
  continue;
1370
1335
  }
@@ -1528,11 +1493,11 @@ export class OneBunApplication {
1528
1493
  * ```
1529
1494
  */
1530
1495
  getService<T>(serviceClass: new (...args: unknown[]) => T): T {
1531
- if (!this.rootModule.getServiceByClass) {
1496
+ if (!this.ensureModule().getServiceByClass) {
1532
1497
  throw new Error('Module does not support getServiceByClass');
1533
1498
  }
1534
1499
 
1535
- const service = this.rootModule.getServiceByClass(serviceClass);
1500
+ const service = this.ensureModule().getServiceByClass!(serviceClass);
1536
1501
  if (!service) {
1537
1502
  throw new Error(
1538
1503
  `Service ${serviceClass.name} not found. Make sure it's registered in the module's providers.`,