@onebun/core 0.1.24 → 0.2.1

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.
@@ -30,6 +30,7 @@ import {
30
30
  getSseMetadata,
31
31
  type SseDecoratorOptions,
32
32
  } from '../decorators/decorators';
33
+ import { OneBunFile, validateFile } from '../file/onebun-file';
33
34
  import {
34
35
  NotInitializedConfig,
35
36
  type IConfig,
@@ -47,6 +48,7 @@ import {
47
48
  type ApplicationOptions,
48
49
  type HttpMethod,
49
50
  type ModuleInstance,
51
+ type OneBunRequest,
50
52
  type ParamMetadata,
51
53
  ParamType,
52
54
  type RouteMetadata,
@@ -113,6 +115,41 @@ function normalizePath(path: string): string {
113
115
  return path.endsWith('/') ? path.slice(0, -1) : path;
114
116
  }
115
117
 
118
+ /**
119
+ * Extract query parameters from URL, handling array notation and repeated keys.
120
+ *
121
+ * @param url - The URL to extract query parameters from
122
+ * @returns Record of parameter names to values (string for single, string[] for repeated/array notation)
123
+ * @example
124
+ * extractQueryParams(new URL('http://x.com/?a=1&b=2')) // { a: '1', b: '2' }
125
+ * extractQueryParams(new URL('http://x.com/?tag=a&tag=b')) // { tag: ['a', 'b'] }
126
+ * extractQueryParams(new URL('http://x.com/?tag[]=a')) // { tag: ['a'] }
127
+ */
128
+ function extractQueryParams(url: URL): Record<string, string | string[]> {
129
+ const queryParams: Record<string, string | string[]> = {};
130
+
131
+ for (const [rawKey, value] of url.searchParams.entries()) {
132
+ // Handle array notation: tag[] -> tag (as array)
133
+ const isArrayNotation = rawKey.endsWith('[]');
134
+ const key = isArrayNotation ? rawKey.replace('[]', '') : rawKey;
135
+
136
+ const existing = queryParams[key];
137
+ if (existing !== undefined) {
138
+ // Handle multiple values with same key (e.g., ?tag=a&tag=b or ?tag[]=a&tag[]=b)
139
+ queryParams[key] = Array.isArray(existing)
140
+ ? [...existing, value]
141
+ : [existing, value];
142
+ } else if (isArrayNotation) {
143
+ // Array notation always creates an array, even with single value
144
+ queryParams[key] = [value];
145
+ } else {
146
+ queryParams[key] = value;
147
+ }
148
+ }
149
+
150
+ return queryParams;
151
+ }
152
+
116
153
  /**
117
154
  * Resolve port from options, environment variable, or default.
118
155
  * Priority: explicit option > PORT env > default (3000)
@@ -431,163 +468,34 @@ export class OneBunApplication {
431
468
  // Initialize Docs (OpenAPI/Swagger) if enabled and available
432
469
  await this.initializeDocs(controllers);
433
470
 
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
- }
471
+ // Create server context binding (used by route handlers and executeHandler)
472
+ const app = this;
536
473
 
537
- // Get metrics path
474
+ // Path constants for framework endpoints
538
475
  const metricsPath = this.options.metrics?.path || '/metrics';
539
-
540
- // Get docs paths
541
476
  const docsPath = this.options.docs?.path || '/docs';
542
477
  const openApiPath = this.options.docs?.jsonPath || '/openapi.json';
543
478
 
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
- }
479
+ // Build application-level path prefix from options
480
+ const appPrefix = this.buildPathPrefix();
587
481
 
588
- return response;
589
- }
590
- }
482
+ // Build Bun routes object: { "/path": { GET: handler, POST: handler } }
483
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
484
+ const bunRoutes: Record<string, any> = {};
485
+
486
+ /**
487
+ * Create a route handler with the full OneBun request lifecycle:
488
+ * tracing setup → middleware chain → executeHandler → metrics → tracing end
489
+ */
490
+ function createRouteHandler(
491
+ routeMeta: RouteMetadata,
492
+ boundHandler: Function,
493
+ controller: Controller,
494
+ fullPath: string,
495
+ method: string,
496
+ ): (req: OneBunRequest) => Promise<Response> {
497
+ return async (req) => {
498
+ const startTime = Date.now();
591
499
 
592
500
  // Setup tracing context if available and enabled
593
501
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -626,7 +534,7 @@ export class OneBunApplication {
626
534
  const httpData = {
627
535
  method,
628
536
  url: req.url,
629
- route: path,
537
+ route: fullPath,
630
538
  userAgent: headers['user-agent'],
631
539
  remoteAddr: headers['x-forwarded-for'] || headers['x-real-ip'],
632
540
  requestSize: headers['content-length']
@@ -648,9 +556,6 @@ export class OneBunApplication {
648
556
  (globalThis as Record<string, unknown>).__onebunCurrentTraceContext =
649
557
  globalCurrentTraceContext;
650
558
  }
651
-
652
- // Propagate trace context to logger
653
- // Note: FiberRef.set should be used within Effect context
654
559
  } catch (error) {
655
560
  app.logger.error(
656
561
  'Failed to setup tracing:',
@@ -659,232 +564,89 @@ export class OneBunApplication {
659
564
  }
660
565
  }
661
566
 
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
- }
567
+ // Extract query parameters from URL
568
+ const url = new URL(req.url);
569
+ const queryParams = extractQueryParams(url);
673
570
 
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
- }
571
+ try {
572
+ let response: Response;
684
573
 
685
- // Handle metrics endpoint
686
- if (path === metricsPath && method === 'GET' && app.metricsService) {
687
- try {
688
- const metrics = await app.metricsService.getMetrics();
574
+ // Execute middleware chain if any, then handler
575
+ if (routeMeta.middleware && routeMeta.middleware.length > 0) {
576
+ const next = async (index: number): Promise<Response> => {
577
+ if (index >= routeMeta.middleware!.length) {
578
+ return await executeHandler(
579
+ {
580
+ handler: boundHandler,
581
+ handlerName: routeMeta.handler,
582
+ controller,
583
+ params: routeMeta.params,
584
+ responseSchemas: routeMeta.responseSchemas,
585
+ },
586
+ req,
587
+ queryParams,
588
+ );
589
+ }
689
590
 
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
- );
591
+ const middleware = routeMeta.middleware![index];
701
592
 
702
- return new Response('Internal Server Error', {
703
- status: HttpStatusCode.INTERNAL_SERVER_ERROR,
704
- });
705
- }
706
- }
707
-
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[]> = {};
593
+ return await middleware(req, () => next(index + 1));
594
+ };
712
595
 
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];
596
+ response = await next(0);
728
597
  } 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
- }
598
+ response = await executeHandler(
599
+ {
600
+ handler: boundHandler,
601
+ handlerName: routeMeta.handler,
602
+ controller,
603
+ params: routeMeta.params,
604
+ responseSchemas: routeMeta.responseSchemas,
605
+ },
606
+ req,
607
+ queryParams,
608
+ );
748
609
  }
749
- }
750
610
 
751
- if (!route) {
752
- const response = new Response('Not Found', {
753
- status: HttpStatusCode.NOT_FOUND,
754
- });
755
611
  const duration = Date.now() - startTime;
756
612
 
757
- // Record metrics for 404
613
+ // Record metrics
758
614
  if (app.metricsService && app.metricsService.recordHttpRequest) {
759
615
  const durationSeconds = duration / 1000;
760
616
  app.metricsService.recordHttpRequest({
761
617
  method,
762
- route: path,
763
- statusCode: HttpStatusCode.NOT_FOUND,
618
+ route: fullPath,
619
+ statusCode: response?.status || HttpStatusCode.OK,
764
620
  duration: durationSeconds,
765
- controller: 'unknown',
621
+ controller: controller.constructor.name,
766
622
  action: 'unknown',
767
623
  });
768
624
  }
769
625
 
770
- // End trace for 404
626
+ // End trace
771
627
  if (traceSpan && app.traceService) {
772
628
  try {
773
629
  await Effect.runPromise(
774
630
  app.traceService.endHttpTrace(traceSpan, {
775
- statusCode: HttpStatusCode.NOT_FOUND,
631
+ statusCode: response?.status || HttpStatusCode.OK,
632
+ responseSize: response?.headers?.get('content-length')
633
+ ? parseInt(response.headers.get('content-length')!, 10)
634
+ : undefined,
776
635
  duration,
777
636
  }),
778
637
  );
779
638
  } catch (traceError) {
780
639
  app.logger.error(
781
- 'Failed to end trace for 404:',
640
+ 'Failed to end trace:',
782
641
  traceError instanceof Error ? traceError : new Error(String(traceError)),
783
642
  );
784
643
  }
785
644
  }
786
645
 
787
- // Clear trace context after 404
646
+ // Clear trace context after request
788
647
  clearGlobalTraceContext();
789
648
 
790
649
  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
650
  } catch (error) {
889
651
  app.logger.error(
890
652
  'Request handling error:',
@@ -900,10 +662,10 @@ export class OneBunApplication {
900
662
  const durationSeconds = duration / 1000;
901
663
  app.metricsService.recordHttpRequest({
902
664
  method,
903
- route: path,
665
+ route: fullPath,
904
666
  statusCode: HttpStatusCode.INTERNAL_SERVER_ERROR,
905
667
  duration: durationSeconds,
906
- controller: route?.controller.constructor.name || 'unknown',
668
+ controller: controller.constructor.name,
907
669
  action: 'unknown',
908
670
  });
909
671
  }
@@ -936,6 +698,179 @@ export class OneBunApplication {
936
698
 
937
699
  return response;
938
700
  }
701
+ };
702
+ }
703
+
704
+ // Add routes from controllers
705
+ for (const controllerClass of controllers) {
706
+ const controllerMetadata = getControllerMetadata(controllerClass);
707
+ if (!controllerMetadata) {
708
+ this.logger.warn(`No metadata found for controller: ${controllerClass.name}`);
709
+ continue;
710
+ }
711
+
712
+ // Get controller instance from module
713
+ if (!this.ensureModule().getControllerInstance) {
714
+ this.logger.warn(
715
+ `Module does not support getControllerInstance for ${controllerClass.name}`,
716
+ );
717
+ continue;
718
+ }
719
+
720
+ const controller = this.ensureModule().getControllerInstance!(controllerClass) as Controller;
721
+ if (!controller) {
722
+ this.logger.warn(`Controller instance not found for ${controllerClass.name}`);
723
+ continue;
724
+ }
725
+
726
+ const controllerPath = controllerMetadata.path;
727
+
728
+ for (const route of controllerMetadata.routes) {
729
+ // Combine: appPrefix + controllerPath + routePath
730
+ // Normalize to ensure consistent matching (e.g., '/api/users/' -> '/api/users')
731
+ const fullPath = normalizePath(`${appPrefix}${controllerPath}${route.path}`);
732
+ const method = this.mapHttpMethod(route.method);
733
+ const handler = (controller as unknown as Record<string, Function>)[route.handler].bind(
734
+ controller,
735
+ );
736
+
737
+ // Create wrapped handler with full OneBun lifecycle (tracing, metrics, middleware)
738
+ const wrappedHandler = createRouteHandler(route, handler, controller, fullPath, method);
739
+
740
+ // Add to bunRoutes grouped by path and method
741
+ if (!bunRoutes[fullPath]) {
742
+ bunRoutes[fullPath] = {};
743
+ }
744
+ bunRoutes[fullPath][method] = wrappedHandler;
745
+
746
+ // Register trailing slash variant for consistent matching
747
+ // (e.g., /api/users and /api/users/ both map to the same handler)
748
+ if (fullPath.length > 1 && !fullPath.endsWith('/')) {
749
+ const trailingPath = fullPath + '/';
750
+ if (!bunRoutes[trailingPath]) {
751
+ bunRoutes[trailingPath] = {};
752
+ }
753
+ bunRoutes[trailingPath][method] = wrappedHandler;
754
+ }
755
+ }
756
+ }
757
+
758
+ // Add framework endpoints to routes (docs, metrics)
759
+ if (app.options.docs?.enabled !== false && app.openApiSpec) {
760
+ if (app.swaggerHtml) {
761
+ bunRoutes[docsPath] = {
762
+
763
+ GET: () => new Response(app.swaggerHtml!, {
764
+ headers: {
765
+ // eslint-disable-next-line @typescript-eslint/naming-convention
766
+ 'Content-Type': 'text/html; charset=utf-8',
767
+ },
768
+ }),
769
+ };
770
+ }
771
+ bunRoutes[openApiPath] = {
772
+
773
+ GET: () => new Response(JSON.stringify(app.openApiSpec, null, 2), {
774
+ headers: {
775
+ // eslint-disable-next-line @typescript-eslint/naming-convention
776
+ 'Content-Type': 'application/json',
777
+ },
778
+ }),
779
+ };
780
+ }
781
+
782
+ if (app.metricsService) {
783
+ bunRoutes[metricsPath] = {
784
+
785
+ async GET() {
786
+ try {
787
+ const metrics = await app.metricsService.getMetrics();
788
+
789
+ return new Response(metrics, {
790
+ headers: {
791
+ // eslint-disable-next-line @typescript-eslint/naming-convention
792
+ 'Content-Type': app.metricsService.getContentType(),
793
+ },
794
+ });
795
+ } catch (error) {
796
+ app.logger.error(
797
+ 'Failed to get metrics:',
798
+ error instanceof Error ? error : new Error(String(error)),
799
+ );
800
+
801
+ return new Response('Internal Server Error', {
802
+ status: HttpStatusCode.INTERNAL_SERVER_ERROR,
803
+ });
804
+ }
805
+ },
806
+ };
807
+ }
808
+
809
+ // Log all routes
810
+ for (const controllerClass of controllers) {
811
+ const metadata = getControllerMetadata(controllerClass);
812
+ if (!metadata) {
813
+ continue;
814
+ }
815
+
816
+ for (const route of metadata.routes) {
817
+ const fullPath = normalizePath(`${appPrefix}${metadata.path}${route.path}`);
818
+ const method = this.mapHttpMethod(route.method);
819
+ this.logger.info(`Mapped {${method}} route: ${fullPath}`);
820
+ }
821
+ }
822
+
823
+ // Call onApplicationInit lifecycle hook for all services and controllers
824
+ if (this.ensureModule().callOnApplicationInit) {
825
+ await this.ensureModule().callOnApplicationInit!();
826
+ this.logger.debug('Application initialization hooks completed');
827
+ }
828
+
829
+ const hasWebSocketGateways = this.wsHandler?.hasGateways() ?? false;
830
+
831
+ // Prepare WebSocket handlers if gateways exist
832
+ // When no gateways, use no-op handlers (required by Bun.serve)
833
+ const wsHandlers = hasWebSocketGateways ? this.wsHandler!.createWebSocketHandlers() : {
834
+
835
+ open() { /* no-op */ },
836
+
837
+ message() { /* no-op */ },
838
+
839
+ close() { /* no-op */ },
840
+
841
+ drain() { /* no-op */ },
842
+ };
843
+
844
+ this.server = Bun.serve<WsClientData>({
845
+ port: this.options.port,
846
+ hostname: this.options.host,
847
+ // WebSocket handlers
848
+ websocket: wsHandlers,
849
+ // Bun routes API: all endpoints are handled here
850
+ routes: bunRoutes,
851
+ // Fallback: only WebSocket upgrade and 404
852
+ async fetch(req, server) {
853
+ // Handle WebSocket upgrade if gateways exist
854
+ if (hasWebSocketGateways && app.wsHandler) {
855
+ const upgradeHeader = req.headers.get('upgrade')?.toLowerCase();
856
+ const socketioEnabled = app.options.websocket?.socketio?.enabled ?? false;
857
+ const socketioPath = app.options.websocket?.socketio?.path ?? '/socket.io';
858
+
859
+ const url = new URL(req.url);
860
+ const path = normalizePath(url.pathname);
861
+ const isSocketIoPath = socketioEnabled && path.startsWith(socketioPath);
862
+ if (upgradeHeader === 'websocket' || isSocketIoPath) {
863
+ const response = await app.wsHandler.handleUpgrade(req, server);
864
+ if (response === undefined) {
865
+ return undefined; // Successfully upgraded
866
+ }
867
+
868
+ return response;
869
+ }
870
+ }
871
+
872
+ // 404 for everything not matched by routes
873
+ return new Response('Not Found', { status: HttpStatusCode.NOT_FOUND });
939
874
  },
940
875
  });
941
876
 
@@ -978,7 +913,46 @@ export class OneBunApplication {
978
913
  }
979
914
 
980
915
  /**
981
- * Execute route handler with parameter injection and validation
916
+ * Extract an OneBunFile from a JSON value.
917
+ * Supports two formats:
918
+ * - String: raw base64 data
919
+ * - Object: { data: string, filename?: string, mimeType?: string }
920
+ */
921
+ function extractFileFromJsonValue(value: unknown): OneBunFile | undefined {
922
+ if (typeof value === 'string' && value.length > 0) {
923
+ return OneBunFile.fromBase64(value);
924
+ }
925
+
926
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
927
+ const obj = value as Record<string, unknown>;
928
+ if (typeof obj.data === 'string' && obj.data.length > 0) {
929
+ return OneBunFile.fromBase64(
930
+ obj.data,
931
+ typeof obj.filename === 'string' ? obj.filename : undefined,
932
+ typeof obj.mimeType === 'string' ? obj.mimeType : undefined,
933
+ );
934
+ }
935
+ }
936
+
937
+ return undefined;
938
+ }
939
+
940
+ /**
941
+ * Extract a file from a JSON body by field name
942
+ */
943
+ function extractFileFromJson(
944
+ jsonBody: Record<string, unknown>,
945
+ fieldName: string,
946
+ ): OneBunFile | undefined {
947
+ const fieldValue = jsonBody[fieldName];
948
+
949
+ return extractFileFromJsonValue(fieldValue);
950
+ }
951
+
952
+ /**
953
+ * Execute route handler with parameter injection and validation.
954
+ * Path parameters come from BunRequest.params (populated by Bun routes API).
955
+ * Query parameters are extracted separately from the URL.
982
956
  */
983
957
  async function executeHandler(
984
958
  route: {
@@ -988,8 +962,8 @@ export class OneBunApplication {
988
962
  params?: ParamMetadata[];
989
963
  responseSchemas?: RouteMetadata['responseSchemas'];
990
964
  },
991
- req: Request,
992
- paramValues: Record<string, string | string[]>,
965
+ req: OneBunRequest,
966
+ queryParams: Record<string, string | string[]>,
993
967
  ): Promise<Response> {
994
968
  // Check if this is an SSE endpoint
995
969
  let sseOptions: SseDecoratorOptions | undefined;
@@ -1015,14 +989,63 @@ export class OneBunApplication {
1015
989
  // Sort params by index to ensure correct order
1016
990
  const sortedParams = [...(route.params || [])].sort((a, b) => a.index - b.index);
1017
991
 
992
+ // Pre-parse body for file upload params (FormData or JSON, cached for all params)
993
+ const needsFileData = sortedParams.some(
994
+ (p) =>
995
+ p.type === ParamType.FILE ||
996
+ p.type === ParamType.FILES ||
997
+ p.type === ParamType.FORM_FIELD,
998
+ );
999
+
1000
+ // Validate that @Body and file decorators are not used on the same method
1001
+ if (needsFileData) {
1002
+ const hasBody = sortedParams.some((p) => p.type === ParamType.BODY);
1003
+ if (hasBody) {
1004
+ throw new Error(
1005
+ 'Cannot use @Body() together with @UploadedFile/@UploadedFiles/@FormField on the same method. ' +
1006
+ 'Both consume the request body. Use file decorators for multipart/base64 uploads.',
1007
+ );
1008
+ }
1009
+ }
1010
+
1011
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1012
+ let formData: any = null;
1013
+ let jsonBody: Record<string, unknown> | null = null;
1014
+ let isMultipart = false;
1015
+
1016
+ if (needsFileData) {
1017
+ const contentType = req.headers.get('content-type') || '';
1018
+
1019
+ if (contentType.includes('multipart/form-data')) {
1020
+ isMultipart = true;
1021
+ try {
1022
+ formData = await req.formData();
1023
+ } catch {
1024
+ formData = null;
1025
+ }
1026
+ } else if (contentType.includes('application/json')) {
1027
+ try {
1028
+ const parsed = await req.json();
1029
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1030
+ jsonBody = parsed as Record<string, unknown>;
1031
+ }
1032
+ } catch {
1033
+ jsonBody = null;
1034
+ }
1035
+ }
1036
+ }
1037
+
1018
1038
  for (const param of sortedParams) {
1019
1039
  switch (param.type) {
1020
1040
  case ParamType.PATH:
1021
- args[param.index] = param.name ? paramValues[param.name] : undefined;
1041
+ // Use req.params from BunRequest (natively populated by Bun routes API)
1042
+ args[param.index] = param.name
1043
+ ? (req.params as Record<string, string>)[param.name]
1044
+ : undefined;
1022
1045
  break;
1023
1046
 
1024
1047
  case ParamType.QUERY:
1025
- args[param.index] = param.name ? paramValues[param.name] : undefined;
1048
+ args[param.index] = param.name ? queryParams[param.name] : undefined;
1026
1049
  break;
1027
1050
 
1028
1051
  case ParamType.BODY:
@@ -1037,6 +1060,10 @@ export class OneBunApplication {
1037
1060
  args[param.index] = param.name ? req.headers.get(param.name) : undefined;
1038
1061
  break;
1039
1062
 
1063
+ case ParamType.COOKIE:
1064
+ args[param.index] = param.name ? req.cookies.get(param.name) ?? undefined : undefined;
1065
+ break;
1066
+
1040
1067
  case ParamType.REQUEST:
1041
1068
  args[param.index] = req;
1042
1069
  break;
@@ -1046,6 +1073,100 @@ export class OneBunApplication {
1046
1073
  args[param.index] = undefined;
1047
1074
  break;
1048
1075
 
1076
+ case ParamType.FILE: {
1077
+ let file: OneBunFile | undefined;
1078
+
1079
+ if (isMultipart && formData && param.name) {
1080
+ const entry = formData.get(param.name);
1081
+ if (entry instanceof File) {
1082
+ file = new OneBunFile(entry);
1083
+ }
1084
+ } else if (jsonBody && param.name) {
1085
+ file = extractFileFromJson(jsonBody, param.name);
1086
+ }
1087
+
1088
+ if (file && param.fileOptions) {
1089
+ validateFile(file, param.fileOptions, param.name);
1090
+ }
1091
+
1092
+ args[param.index] = file;
1093
+ break;
1094
+ }
1095
+
1096
+ case ParamType.FILES: {
1097
+ let files: OneBunFile[] = [];
1098
+
1099
+ if (isMultipart && formData) {
1100
+ if (param.name) {
1101
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1102
+ const entries: any[] = formData.getAll(param.name);
1103
+ files = entries
1104
+ .filter((entry: unknown): entry is File => entry instanceof File)
1105
+ .map((f: File) => new OneBunFile(f));
1106
+ } else {
1107
+ // Get all files from all fields
1108
+ for (const [, value] of formData.entries()) {
1109
+ if (value instanceof File) {
1110
+ files.push(new OneBunFile(value));
1111
+ }
1112
+ }
1113
+ }
1114
+ } else if (jsonBody) {
1115
+ if (param.name) {
1116
+ const fieldValue = jsonBody[param.name];
1117
+ if (Array.isArray(fieldValue)) {
1118
+ files = fieldValue
1119
+ .map((item) => extractFileFromJsonValue(item))
1120
+ .filter((f): f is OneBunFile => f !== undefined);
1121
+ }
1122
+ } else {
1123
+ // Extract all file-like values from JSON
1124
+ for (const [, value] of Object.entries(jsonBody)) {
1125
+ const file = extractFileFromJsonValue(value);
1126
+ if (file) {
1127
+ files.push(file);
1128
+ }
1129
+ }
1130
+ }
1131
+ }
1132
+
1133
+ // Validate maxCount
1134
+ if (param.fileOptions?.maxCount !== undefined && files.length > param.fileOptions.maxCount) {
1135
+ throw new Error(
1136
+ `Too many files for "${param.name || 'upload'}". Got ${files.length}, max is ${param.fileOptions.maxCount}`,
1137
+ );
1138
+ }
1139
+
1140
+ // Validate each file
1141
+ if (param.fileOptions) {
1142
+ for (const file of files) {
1143
+ validateFile(file, param.fileOptions, param.name);
1144
+ }
1145
+ }
1146
+
1147
+ args[param.index] = files;
1148
+ break;
1149
+ }
1150
+
1151
+ case ParamType.FORM_FIELD: {
1152
+ let value: string | undefined;
1153
+
1154
+ if (isMultipart && formData && param.name) {
1155
+ const entry = formData.get(param.name);
1156
+ if (typeof entry === 'string') {
1157
+ value = entry;
1158
+ }
1159
+ } else if (jsonBody && param.name) {
1160
+ const jsonValue = jsonBody[param.name];
1161
+ if (jsonValue !== undefined && jsonValue !== null) {
1162
+ value = String(jsonValue);
1163
+ }
1164
+ }
1165
+
1166
+ args[param.index] = value;
1167
+ break;
1168
+ }
1169
+
1049
1170
  default:
1050
1171
  args[param.index] = undefined;
1051
1172
  }
@@ -1055,6 +1176,16 @@ export class OneBunApplication {
1055
1176
  throw new Error(`Required parameter ${param.name || param.index} is missing`);
1056
1177
  }
1057
1178
 
1179
+ // For FILES type, also check for empty array when required
1180
+ if (
1181
+ param.isRequired &&
1182
+ param.type === ParamType.FILES &&
1183
+ Array.isArray(args[param.index]) &&
1184
+ (args[param.index] as unknown[]).length === 0
1185
+ ) {
1186
+ throw new Error(`Required parameter ${param.name || param.index} is missing`);
1187
+ }
1188
+
1058
1189
  // Apply arktype schema validation if provided
1059
1190
  if (param.schema && args[param.index] !== undefined) {
1060
1191
  try {
@@ -1085,7 +1216,6 @@ export class OneBunApplication {
1085
1216
  // If the result is already a Response object, extract body and validate it
1086
1217
  if (result instanceof Response) {
1087
1218
  responseStatusCode = result.status;
1088
- const responseHeaders = Object.fromEntries(result.headers.entries());
1089
1219
 
1090
1220
  // Extract and parse response body for validation
1091
1221
  const contentType = result.headers.get('content-type') || '';
@@ -1119,14 +1249,16 @@ export class OneBunApplication {
1119
1249
  validatedResult = bodyData;
1120
1250
  }
1121
1251
 
1252
+ // Preserve all original headers (including multiple Set-Cookie)
1253
+ // using new Headers() constructor instead of Object.fromEntries()
1254
+ // which would lose duplicate header keys
1255
+ const newHeaders = new Headers(result.headers);
1256
+ newHeaders.set('Content-Type', 'application/json');
1257
+
1122
1258
  // Create new Response with validated data
1123
1259
  return new Response(JSON.stringify(validatedResult), {
1124
1260
  status: responseStatusCode,
1125
- headers: {
1126
- ...responseHeaders,
1127
- // eslint-disable-next-line @typescript-eslint/naming-convention
1128
- 'Content-Type': 'application/json',
1129
- },
1261
+ headers: newHeaders,
1130
1262
  });
1131
1263
  } catch {
1132
1264
  // If parsing fails, return original response