@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.
- package/package.json +8 -8
- package/src/application/application.test.ts +492 -19
- package/src/application/application.ts +490 -358
- package/src/decorators/decorators.test.ts +139 -0
- package/src/decorators/decorators.ts +127 -0
- package/src/docs-examples.test.ts +670 -71
- package/src/file/index.ts +8 -0
- package/src/file/onebun-file.test.ts +315 -0
- package/src/file/onebun-file.ts +304 -0
- package/src/index.ts +13 -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 +45 -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
|
@@ -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
|
|
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
|
-
}
|
|
471
|
+
// Create server context binding (used by route handlers and executeHandler)
|
|
472
|
+
const app = this;
|
|
536
473
|
|
|
537
|
-
//
|
|
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
|
-
//
|
|
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
|
-
}
|
|
479
|
+
// Build application-level path prefix from options
|
|
480
|
+
const appPrefix = this.buildPathPrefix();
|
|
587
481
|
|
|
588
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
}
|
|
567
|
+
// Extract query parameters from URL
|
|
568
|
+
const url = new URL(req.url);
|
|
569
|
+
const queryParams = extractQueryParams(url);
|
|
673
570
|
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
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
|
-
|
|
703
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
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:
|
|
763
|
-
statusCode: HttpStatusCode.
|
|
618
|
+
route: fullPath,
|
|
619
|
+
statusCode: response?.status || HttpStatusCode.OK,
|
|
764
620
|
duration: durationSeconds,
|
|
765
|
-
controller:
|
|
621
|
+
controller: controller.constructor.name,
|
|
766
622
|
action: 'unknown',
|
|
767
623
|
});
|
|
768
624
|
}
|
|
769
625
|
|
|
770
|
-
// End trace
|
|
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.
|
|
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
|
|
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
|
|
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:
|
|
665
|
+
route: fullPath,
|
|
904
666
|
statusCode: HttpStatusCode.INTERNAL_SERVER_ERROR,
|
|
905
667
|
duration: durationSeconds,
|
|
906
|
-
controller:
|
|
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
|
-
*
|
|
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:
|
|
992
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|