@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.
- package/package.json +8 -8
- package/src/application/application.test.ts +492 -19
- package/src/application/application.ts +302 -358
- package/src/decorators/decorators.ts +12 -0
- package/src/docs-examples.test.ts +300 -1
- package/src/index.ts +2 -0
- package/src/module/controller.ts +7 -3
- package/src/queue/docs-examples.test.ts +86 -0
- package/src/service-client/service-client.test.ts +1 -1
- package/src/types.ts +18 -2
- package/src/validation/schemas.test.ts +0 -2
- package/src/websocket/ws-base-gateway.ts +2 -2
- package/src/websocket/ws-handler.ts +4 -3
- package/src/websocket/ws.types.ts +1 -1
|
@@ -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
|
|
435
|
-
const
|
|
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
|
-
//
|
|
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
|
-
//
|
|
545
|
-
const
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
try {
|
|
688
|
-
const metrics = await app.metricsService.getMetrics();
|
|
570
|
+
try {
|
|
571
|
+
let response: Response;
|
|
689
572
|
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
703
|
-
status: HttpStatusCode.INTERNAL_SERVER_ERROR,
|
|
704
|
-
});
|
|
705
|
-
}
|
|
706
|
-
}
|
|
590
|
+
const middleware = routeMeta.middleware![index];
|
|
707
591
|
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
|
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:
|
|
763
|
-
statusCode: HttpStatusCode.
|
|
617
|
+
route: fullPath,
|
|
618
|
+
statusCode: response?.status || HttpStatusCode.OK,
|
|
764
619
|
duration: durationSeconds,
|
|
765
|
-
controller:
|
|
620
|
+
controller: controller.constructor.name,
|
|
766
621
|
action: 'unknown',
|
|
767
622
|
});
|
|
768
623
|
}
|
|
769
624
|
|
|
770
|
-
// End trace
|
|
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.
|
|
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
|
|
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
|
|
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:
|
|
664
|
+
route: fullPath,
|
|
904
665
|
statusCode: HttpStatusCode.INTERNAL_SERVER_ERROR,
|
|
905
666
|
duration: durationSeconds,
|
|
906
|
-
controller:
|
|
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:
|
|
992
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|