@onebun/core 0.2.6 → 0.2.8

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.
Files changed (33) hide show
  1. package/package.json +6 -6
  2. package/src/application/application.test.ts +350 -7
  3. package/src/application/application.ts +537 -254
  4. package/src/application/multi-service-application.test.ts +15 -0
  5. package/src/application/multi-service-application.ts +2 -0
  6. package/src/application/multi-service.types.ts +7 -1
  7. package/src/decorators/decorators.ts +213 -0
  8. package/src/docs-examples.test.ts +386 -3
  9. package/src/exception-filters/exception-filters.test.ts +172 -0
  10. package/src/exception-filters/exception-filters.ts +129 -0
  11. package/src/exception-filters/http-exception.ts +22 -0
  12. package/src/exception-filters/index.ts +2 -0
  13. package/src/file/onebun-file.ts +8 -2
  14. package/src/http-guards/http-guards.test.ts +230 -0
  15. package/src/http-guards/http-guards.ts +173 -0
  16. package/src/http-guards/index.ts +1 -0
  17. package/src/index.ts +10 -0
  18. package/src/module/module.test.ts +78 -0
  19. package/src/module/module.ts +55 -7
  20. package/src/queue/docs-examples.test.ts +72 -12
  21. package/src/queue/index.ts +4 -0
  22. package/src/queue/queue-service-proxy.test.ts +82 -0
  23. package/src/queue/queue-service-proxy.ts +114 -0
  24. package/src/queue/types.ts +2 -2
  25. package/src/security/cors-middleware.ts +212 -0
  26. package/src/security/index.ts +19 -0
  27. package/src/security/rate-limit-middleware.ts +276 -0
  28. package/src/security/security-headers-middleware.ts +188 -0
  29. package/src/security/security.test.ts +285 -0
  30. package/src/testing/index.ts +1 -0
  31. package/src/testing/testing-module.test.ts +199 -0
  32. package/src/testing/testing-module.ts +252 -0
  33. package/src/types.ts +153 -3
@@ -1,4 +1,10 @@
1
- import { Effect, type Layer } from 'effect';
1
+ import path from 'node:path';
2
+
3
+ import {
4
+ type Context,
5
+ Effect,
6
+ type Layer,
7
+ } from 'effect';
2
8
 
3
9
  import type { Controller } from '../module/controller';
4
10
  import type { WsClientData } from '../websocket/ws.types';
@@ -17,21 +23,24 @@ import {
17
23
  type SyncLogger,
18
24
  } from '@onebun/logger';
19
25
  import {
20
- type ApiResponse,
21
26
  createErrorResponse,
22
27
  createSuccessResponse,
23
28
  HttpStatusCode,
24
- OneBunBaseError,
25
29
  } from '@onebun/requests';
26
30
  import { makeTraceService, TraceService } from '@onebun/trace';
27
31
 
28
32
  import {
33
+ getControllerFilters,
34
+ getControllerGuards,
29
35
  getControllerMetadata,
30
36
  getControllerMiddleware,
31
37
  getSseMetadata,
32
38
  type SseDecoratorOptions,
33
39
  } from '../decorators/decorators';
40
+ import { defaultExceptionFilter, type ExceptionFilter } from '../exception-filters/exception-filters';
41
+ import { HttpException } from '../exception-filters/http-exception';
34
42
  import { OneBunFile, validateFile } from '../file/onebun-file';
43
+ import { executeHttpGuards, HttpExecutionContextImpl } from '../http-guards/http-guards';
35
44
  import {
36
45
  NotInitializedConfig,
37
46
  type IConfig,
@@ -45,11 +54,20 @@ import {
45
54
  DEFAULT_SSE_TIMEOUT,
46
55
  } from '../module/controller';
47
56
  import { OneBunModule } from '../module/module';
48
- import { QueueService, type QueueAdapter } from '../queue';
57
+ import {
58
+ QueueService,
59
+ QueueServiceProxy,
60
+ QueueServiceTag,
61
+ type QueueAdapter,
62
+ type QueueConfig,
63
+ } from '../queue';
49
64
  import { InMemoryQueueAdapter } from '../queue/adapters/memory.adapter';
50
65
  import { RedisQueueAdapter } from '../queue/adapters/redis.adapter';
51
66
  import { hasQueueDecorators } from '../queue/decorators';
52
67
  import { SharedRedisProvider } from '../redis/shared-redis';
68
+ import { CorsMiddleware } from '../security/cors-middleware';
69
+ import { RateLimitMiddleware } from '../security/rate-limit-middleware';
70
+ import { SecurityHeadersMiddleware } from '../security/security-headers-middleware';
53
71
  import {
54
72
  type ApplicationOptions,
55
73
  type HttpMethod,
@@ -88,6 +106,17 @@ try {
88
106
  // Docs module not available - this is optional
89
107
  }
90
108
 
109
+ // Optional CacheService for static file existence caching
110
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
+ let cacheServiceClass: new (...args: unknown[]) => any = null as any;
112
+ try {
113
+ // eslint-disable-next-line import/no-extraneous-dependencies
114
+ const cacheModule = require('@onebun/cache');
115
+ cacheServiceClass = cacheModule.CacheService;
116
+ } catch {
117
+ // @onebun/cache not available - use in-memory Map fallback
118
+ }
119
+
91
120
  // Import tracing modules directly
92
121
 
93
122
  // Global trace context for current request
@@ -113,12 +142,12 @@ function clearGlobalTraceContext(): void {
113
142
  * normalizePath('/') // => '/'
114
143
  * normalizePath('/api/v1/') // => '/api/v1'
115
144
  */
116
- function normalizePath(path: string): string {
117
- if (path === '/' || path.length <= 1) {
118
- return path;
145
+ function normalizePath(pathStr: string): string {
146
+ if (pathStr === '/' || pathStr.length <= 1) {
147
+ return pathStr;
119
148
  }
120
149
 
121
- return path.endsWith('/') ? path.slice(0, -1) : path;
150
+ return pathStr.endsWith('/') ? pathStr.slice(0, -1) : pathStr;
122
151
  }
123
152
 
124
153
  /**
@@ -191,6 +220,29 @@ function resolveHost(explicitHost: string | undefined): string {
191
220
  return '0.0.0.0';
192
221
  }
193
222
 
223
+ /** Default TTL for static file existence cache (ms) when not specified */
224
+ const DEFAULT_STATIC_FILE_EXISTENCE_CACHE_TTL_MS = 60_000;
225
+
226
+ /** Cache key prefix for static file existence in CacheService */
227
+ const STATIC_EXISTS_CACHE_PREFIX = 'onebun:static:exists:';
228
+
229
+ /**
230
+ * Resolve a relative path under a root directory and ensure the result stays inside the root (path traversal protection).
231
+ * @param rootDir - Absolute path to the static root directory
232
+ * @param relativePath - URL path segment (e.g. from request path after prefix); must not contain '..' that escapes root
233
+ * @returns Absolute path if under root, otherwise null
234
+ */
235
+ function resolvePathUnderRoot(rootDir: string, relativePath: string): string | null {
236
+ const normalized = path.join(rootDir, relativePath.startsWith('/') ? relativePath.slice(1) : relativePath);
237
+ const resolved = path.resolve(normalized);
238
+ const rootResolved = path.resolve(rootDir);
239
+ if (resolved === rootResolved || resolved.startsWith(rootResolved + path.sep)) {
240
+ return resolved;
241
+ }
242
+
243
+ return null;
244
+ }
245
+
194
246
  /**
195
247
  * OneBun Application
196
248
  */
@@ -210,6 +262,7 @@ export class OneBunApplication {
210
262
  private wsHandler: WsHandler | null = null;
211
263
  private queueService: QueueService | null = null;
212
264
  private queueAdapter: QueueAdapter | null = null;
265
+ private queueServiceProxy: QueueServiceProxy | null = null;
213
266
  // Docs (OpenAPI/Swagger) - generated on start()
214
267
  private openApiSpec: Record<string, unknown> | null = null;
215
268
  private swaggerHtml: string | null = null;
@@ -364,11 +417,11 @@ export class OneBunApplication {
364
417
  * const port = app.getConfigValue('server.port'); // number
365
418
  * const host = app.getConfigValue('server.host'); // string
366
419
  */
367
- getConfigValue<P extends DeepPaths<OneBunAppConfig>>(path: P): DeepValue<OneBunAppConfig, P>;
420
+ getConfigValue<P extends DeepPaths<OneBunAppConfig>>(pathKey: P): DeepValue<OneBunAppConfig, P>;
368
421
  /** Fallback for dynamic paths */
369
- getConfigValue<T = unknown>(path: string): T;
370
- getConfigValue(path: string): unknown {
371
- return this.getConfig().get(path);
422
+ getConfigValue<T = unknown>(pathKey: string): T;
423
+ getConfigValue(pathKey: string): unknown {
424
+ return this.getConfig().get(pathKey);
372
425
  }
373
426
 
374
427
  /**
@@ -441,6 +494,23 @@ export class OneBunApplication {
441
494
  // so services can safely use this.config.get() in their constructors
442
495
  this.rootModule = OneBunModule.create(this.moduleClass, this.loggerLayer, this.config);
443
496
 
497
+ // Register QueueService proxy in DI so controllers/providers/middleware can inject QueueService.
498
+ // After initializeQueue(), setDelegate(real) is called when queue is enabled.
499
+ this.queueServiceProxy = new QueueServiceProxy();
500
+ if (this.ensureModule().registerService) {
501
+ this.ensureModule().registerService!(
502
+ QueueServiceTag as Context.Tag<unknown, QueueService>,
503
+ this.queueServiceProxy as unknown as QueueService,
504
+ );
505
+ }
506
+
507
+ // Register test provider overrides (must happen before setup() so controllers receive mocks)
508
+ if (this.options._testProviders) {
509
+ for (const { tag, value } of this.options._testProviders) {
510
+ this.ensureModule().registerService?.(tag, value);
511
+ }
512
+ }
513
+
444
514
  // Start metrics collection if enabled
445
515
  if (this.metricsService && this.metricsService.startSystemMetricsCollection) {
446
516
  this.metricsService.startSystemMetricsCollection();
@@ -595,41 +665,65 @@ export class OneBunApplication {
595
665
  try {
596
666
  let response: Response;
597
667
 
598
- // Execute middleware chain if any, then handler
599
- if (routeMeta.middleware && routeMeta.middleware.length > 0) {
600
- const next = async (index: number): Promise<Response> => {
601
- if (index >= routeMeta.middleware!.length) {
602
- return await executeHandler(
668
+ // Run guards then handler — shared by middleware chain and direct path
669
+ const runGuardedHandler = async (): Promise<Response> => {
670
+ if (routeMeta.guards && routeMeta.guards.length > 0) {
671
+ const guardCtx = new HttpExecutionContextImpl(
672
+ req,
673
+ routeMeta.handler,
674
+ controller.constructor.name,
675
+ );
676
+ const allowed = await executeHttpGuards(routeMeta.guards, guardCtx);
677
+
678
+ if (!allowed) {
679
+ return new Response(
680
+ JSON.stringify(
681
+ createErrorResponse(
682
+ 'Forbidden',
683
+ HttpStatusCode.FORBIDDEN,
684
+ 'Forbidden',
685
+ ),
686
+ ),
603
687
  {
604
- handler: boundHandler,
605
- handlerName: routeMeta.handler,
606
- controller,
607
- params: routeMeta.params,
608
- responseSchemas: routeMeta.responseSchemas,
688
+ status: HttpStatusCode.OK,
689
+ headers: {
690
+ // eslint-disable-next-line @typescript-eslint/naming-convention
691
+ 'Content-Type': 'application/json',
692
+ },
609
693
  },
610
- req,
611
- queryParams,
612
694
  );
613
695
  }
696
+ }
614
697
 
615
- const middleware = routeMeta.middleware![index];
616
-
617
- return await middleware(req, () => next(index + 1));
618
- };
619
-
620
- response = await next(0);
621
- } else {
622
- response = await executeHandler(
698
+ return await executeHandler(
623
699
  {
624
700
  handler: boundHandler,
625
701
  handlerName: routeMeta.handler,
626
702
  controller,
627
703
  params: routeMeta.params,
628
704
  responseSchemas: routeMeta.responseSchemas,
705
+ filters: routeMeta.filters,
629
706
  },
630
707
  req,
631
708
  queryParams,
632
709
  );
710
+ };
711
+
712
+ // Execute middleware chain if any, then guarded handler
713
+ if (routeMeta.middleware && routeMeta.middleware.length > 0) {
714
+ const next = async (index: number): Promise<Response> => {
715
+ if (index >= routeMeta.middleware!.length) {
716
+ return await runGuardedHandler();
717
+ }
718
+
719
+ const middleware = routeMeta.middleware![index];
720
+
721
+ return await middleware(req, () => next(index + 1));
722
+ };
723
+
724
+ response = await next(0);
725
+ } else {
726
+ response = await runGuardedHandler();
633
727
  }
634
728
 
635
729
  const duration = Date.now() - startTime;
@@ -725,10 +819,31 @@ export class OneBunApplication {
725
819
  };
726
820
  }
727
821
 
822
+ // Build auto-configured security middleware from shorthand options.
823
+ // These are prepended/appended in a fixed order: CORS → RateLimit → [user] → Security.
824
+ const autoPrefix: Function[] = [];
825
+ const autoSuffix: Function[] = [];
826
+
827
+ if (this.options.cors) {
828
+ const corsOpts = this.options.cors === true ? {} : this.options.cors;
829
+ autoPrefix.push(CorsMiddleware.configure(corsOpts));
830
+ }
831
+
832
+ if (this.options.rateLimit) {
833
+ const rlOpts = this.options.rateLimit === true ? {} : this.options.rateLimit;
834
+ autoPrefix.push(RateLimitMiddleware.configure(rlOpts));
835
+ }
836
+
837
+ if (this.options.security) {
838
+ const secOpts = this.options.security === true ? {} : this.options.security;
839
+ autoSuffix.push(SecurityHeadersMiddleware.configure(secOpts));
840
+ }
841
+
728
842
  // Application-wide middleware — resolve class constructors via root module DI
729
- const globalMiddlewareClasses = (this.options.middleware as Function[] | undefined) ?? [];
730
- const globalMiddleware: Function[] = globalMiddlewareClasses.length > 0
731
- ? (this.ensureModule().resolveMiddleware?.(globalMiddlewareClasses) ?? [])
843
+ const userMiddlewareClasses = (this.options.middleware as Function[] | undefined) ?? [];
844
+ const allGlobalClasses = [...autoPrefix, ...userMiddlewareClasses, ...autoSuffix];
845
+ const globalMiddleware: Function[] = allGlobalClasses.length > 0
846
+ ? (this.ensureModule().resolveMiddleware?.(allGlobalClasses) ?? [])
732
847
  : [];
733
848
 
734
849
  // Add routes from controllers
@@ -790,9 +905,23 @@ export class OneBunApplication {
790
905
  ...ctrlMiddleware,
791
906
  ...routeMiddleware,
792
907
  ];
908
+
909
+ // Merge guards: controller-level first, then route-level
910
+ const ctrlGuards = getControllerGuards(controllerClass);
911
+ const routeGuards = route.guards ?? [];
912
+ const mergedGuards = [...ctrlGuards, ...routeGuards];
913
+
914
+ // Merge exception filters: global → controller → route (route has highest priority)
915
+ const globalFilters = (this.options.filters as ExceptionFilter[] | undefined) ?? [];
916
+ const ctrlFilters = getControllerFilters(controllerClass);
917
+ const routeFilters = route.filters ?? [];
918
+ const mergedFilters = [...globalFilters, ...ctrlFilters, ...routeFilters];
919
+
793
920
  const routeWithMergedMiddleware: RouteMetadata = {
794
921
  ...route,
795
922
  middleware: mergedMiddleware.length > 0 ? mergedMiddleware : undefined,
923
+ guards: mergedGuards.length > 0 ? mergedGuards : undefined,
924
+ filters: mergedFilters.length > 0 ? mergedFilters : undefined,
796
925
  };
797
926
 
798
927
  // Create wrapped handler with full OneBun lifecycle (tracing, metrics, middleware)
@@ -901,6 +1030,69 @@ export class OneBunApplication {
901
1030
 
902
1031
  drain() { /* no-op */ },
903
1032
  };
1033
+
1034
+ // Static file serving: resolve root and setup existence cache (CacheService or in-memory Map)
1035
+ const staticOpts = this.options.static;
1036
+ let staticRootResolved: string | null = null;
1037
+ let staticPathPrefix: string | undefined;
1038
+ let staticFallbackFile: string | undefined;
1039
+ let staticCacheTtlMs = 0;
1040
+ // Cache interface: get(key) => value | undefined, set(key, value, ttlMs)
1041
+ type StaticExistsCache = {
1042
+ get(key: string): Promise<boolean | undefined>;
1043
+ set(key: string, value: boolean, ttlMs: number): Promise<void>;
1044
+ };
1045
+ let staticExistsCache: StaticExistsCache | null = null;
1046
+
1047
+ if (staticOpts?.root) {
1048
+ staticRootResolved = path.resolve(staticOpts.root);
1049
+ staticPathPrefix = staticOpts.pathPrefix;
1050
+ staticFallbackFile = staticOpts.fallbackFile;
1051
+ staticCacheTtlMs = staticOpts.fileExistenceCacheTtlMs ?? DEFAULT_STATIC_FILE_EXISTENCE_CACHE_TTL_MS;
1052
+
1053
+ if (staticCacheTtlMs > 0) {
1054
+ if (cacheServiceClass) {
1055
+ try {
1056
+ const cacheService = this.ensureModule().getServiceByClass?.(cacheServiceClass);
1057
+ if (cacheService && typeof cacheService.get === 'function' && typeof cacheService.set === 'function') {
1058
+ staticExistsCache = {
1059
+ get: (key: string) => cacheService.get(STATIC_EXISTS_CACHE_PREFIX + key),
1060
+ set: (key: string, value: boolean, ttlMs: number) =>
1061
+ cacheService.set(STATIC_EXISTS_CACHE_PREFIX + key, value, { ttl: ttlMs }),
1062
+ };
1063
+ this.logger.debug('Static file existence cache using CacheService');
1064
+ }
1065
+ } catch {
1066
+ // CacheService not in module, use fallback
1067
+ }
1068
+ }
1069
+ if (!staticExistsCache) {
1070
+ const map = new Map<string, { exists: boolean; expiresAt: number }>();
1071
+ staticExistsCache = {
1072
+ async get(key: string): Promise<boolean | undefined> {
1073
+ const entry = map.get(key);
1074
+ if (!entry) {
1075
+ return undefined;
1076
+ }
1077
+ if (staticCacheTtlMs > 0 && Date.now() > entry.expiresAt) {
1078
+ map.delete(key);
1079
+
1080
+ return undefined;
1081
+ }
1082
+
1083
+ return entry.exists;
1084
+ },
1085
+ async set(key: string, value: boolean, ttlMs: number): Promise<void> {
1086
+ map.set(key, {
1087
+ exists: value,
1088
+ expiresAt: ttlMs > 0 ? Date.now() + ttlMs : Number.MAX_SAFE_INTEGER,
1089
+ });
1090
+ },
1091
+ };
1092
+ this.logger.debug('Static file existence cache using in-memory Map');
1093
+ }
1094
+ }
1095
+ }
904
1096
 
905
1097
  this.server = Bun.serve<WsClientData>({
906
1098
  port: this.options.port,
@@ -920,8 +1112,8 @@ export class OneBunApplication {
920
1112
  const socketioPath = app.options.websocket?.socketio?.path ?? '/socket.io';
921
1113
 
922
1114
  const url = new URL(req.url);
923
- const path = normalizePath(url.pathname);
924
- const isSocketIoPath = socketioEnabled && path.startsWith(socketioPath);
1115
+ const requestPath = normalizePath(url.pathname);
1116
+ const isSocketIoPath = socketioEnabled && requestPath.startsWith(socketioPath);
925
1117
  if (upgradeHeader === 'websocket' || isSocketIoPath) {
926
1118
  const response = await app.wsHandler.handleUpgrade(req, server);
927
1119
  if (response === undefined) {
@@ -932,6 +1124,60 @@ export class OneBunApplication {
932
1124
  }
933
1125
  }
934
1126
 
1127
+ // Static file serving (GET/HEAD only)
1128
+ if (staticRootResolved && (req.method === 'GET' || req.method === 'HEAD')) {
1129
+ const requestPath = normalizePath(new URL(req.url).pathname);
1130
+ const prefix = staticPathPrefix ?? '/';
1131
+ const hasPrefix = prefix !== '' && prefix !== '/';
1132
+ if (hasPrefix && !requestPath.startsWith(prefix)) {
1133
+ return new Response('Not Found', { status: HttpStatusCode.NOT_FOUND });
1134
+ }
1135
+ const relativePath = hasPrefix ? requestPath.slice(prefix.length) || '/' : requestPath;
1136
+ const resolvedPath = resolvePathUnderRoot(staticRootResolved, relativePath);
1137
+ if (resolvedPath === null) {
1138
+ return new Response('Not Found', { status: HttpStatusCode.NOT_FOUND });
1139
+ }
1140
+
1141
+ const cache = staticCacheTtlMs > 0 ? staticExistsCache : null;
1142
+ const cacheKey = resolvedPath;
1143
+ if (cache) {
1144
+ const cached = await cache.get(cacheKey);
1145
+ if (cached === true) {
1146
+ return new Response(Bun.file(resolvedPath));
1147
+ }
1148
+ if (cached === false) {
1149
+ if (staticFallbackFile) {
1150
+ const fallbackResolved = resolvePathUnderRoot(staticRootResolved, staticFallbackFile);
1151
+ if (fallbackResolved !== null) {
1152
+ return new Response(Bun.file(fallbackResolved));
1153
+ }
1154
+ }
1155
+
1156
+ return new Response('Not Found', { status: HttpStatusCode.NOT_FOUND });
1157
+ }
1158
+ }
1159
+
1160
+ const file = Bun.file(resolvedPath);
1161
+ const exists = await file.exists();
1162
+ if (cache && staticCacheTtlMs > 0) {
1163
+ await cache.set(cacheKey, exists, staticCacheTtlMs);
1164
+ }
1165
+ if (exists) {
1166
+ return new Response(file);
1167
+ }
1168
+ if (staticFallbackFile) {
1169
+ const fallbackResolved = resolvePathUnderRoot(staticRootResolved, staticFallbackFile);
1170
+ if (fallbackResolved !== null) {
1171
+ const fallbackFile = Bun.file(fallbackResolved);
1172
+ if (await fallbackFile.exists()) {
1173
+ return new Response(fallbackFile);
1174
+ }
1175
+ }
1176
+ }
1177
+
1178
+ return new Response('Not Found', { status: HttpStatusCode.NOT_FOUND });
1179
+ }
1180
+
935
1181
  // 404 for everything not matched by routes
936
1182
  return new Response('Not Found', { status: HttpStatusCode.NOT_FOUND });
937
1183
  },
@@ -953,6 +1199,9 @@ export class OneBunApplication {
953
1199
  }
954
1200
 
955
1201
  this.logger.info(`Server started on http://${this.options.host}:${this.options.port}`);
1202
+ if (staticRootResolved) {
1203
+ this.logger.info(`Static files served from ${staticRootResolved}`);
1204
+ }
956
1205
  if (this.metricsService) {
957
1206
  this.logger.info(
958
1207
  `Metrics available at http://${this.options.host}:${this.options.port}${metricsPath}`,
@@ -1024,6 +1273,7 @@ export class OneBunApplication {
1024
1273
  controller: Controller;
1025
1274
  params?: ParamMetadata[];
1026
1275
  responseSchemas?: RouteMetadata['responseSchemas'];
1276
+ filters?: ExceptionFilter[];
1027
1277
  },
1028
1278
  req: OneBunRequest,
1029
1279
  queryParams: Record<string, string | string[]>,
@@ -1046,224 +1296,226 @@ export class OneBunApplication {
1046
1296
  return result;
1047
1297
  }
1048
1298
 
1299
+ try {
1049
1300
  // Prepare arguments array based on parameter metadata
1050
- const args: unknown[] = [];
1301
+ const args: unknown[] = [];
1051
1302
 
1052
- // Sort params by index to ensure correct order
1053
- const sortedParams = [...(route.params || [])].sort((a, b) => a.index - b.index);
1303
+ // Sort params by index to ensure correct order
1304
+ const sortedParams = [...(route.params || [])].sort((a, b) => a.index - b.index);
1054
1305
 
1055
- // Pre-parse body for file upload params (FormData or JSON, cached for all params)
1056
- const needsFileData = sortedParams.some(
1057
- (p) =>
1058
- p.type === ParamType.FILE ||
1306
+ // Pre-parse body for file upload params (FormData or JSON, cached for all params)
1307
+ const needsFileData = sortedParams.some(
1308
+ (p) =>
1309
+ p.type === ParamType.FILE ||
1059
1310
  p.type === ParamType.FILES ||
1060
1311
  p.type === ParamType.FORM_FIELD,
1061
- );
1312
+ );
1062
1313
 
1063
- // Validate that @Body and file decorators are not used on the same method
1064
- if (needsFileData) {
1065
- const hasBody = sortedParams.some((p) => p.type === ParamType.BODY);
1066
- if (hasBody) {
1067
- throw new Error(
1068
- 'Cannot use @Body() together with @UploadedFile/@UploadedFiles/@FormField on the same method. ' +
1314
+ // Validate that @Body and file decorators are not used on the same method
1315
+ if (needsFileData) {
1316
+ const hasBody = sortedParams.some((p) => p.type === ParamType.BODY);
1317
+ if (hasBody) {
1318
+ throw new HttpException(
1319
+ HttpStatusCode.BAD_REQUEST,
1320
+ 'Cannot use @Body() together with @UploadedFile/@UploadedFiles/@FormField on the same method. ' +
1069
1321
  'Both consume the request body. Use file decorators for multipart/base64 uploads.',
1070
- );
1322
+ );
1323
+ }
1071
1324
  }
1072
- }
1073
1325
 
1074
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1075
- let formData: any = null;
1076
- let jsonBody: Record<string, unknown> | null = null;
1077
- let isMultipart = false;
1326
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1327
+ let formData: any = null;
1328
+ let jsonBody: Record<string, unknown> | null = null;
1329
+ let isMultipart = false;
1078
1330
 
1079
- if (needsFileData) {
1080
- const contentType = req.headers.get('content-type') || '';
1331
+ if (needsFileData) {
1332
+ const contentType = req.headers.get('content-type') || '';
1081
1333
 
1082
- if (contentType.includes('multipart/form-data')) {
1083
- isMultipart = true;
1084
- try {
1085
- formData = await req.formData();
1086
- } catch {
1087
- formData = null;
1088
- }
1089
- } else if (contentType.includes('application/json')) {
1090
- try {
1091
- const parsed = await req.json();
1092
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1093
- jsonBody = parsed as Record<string, unknown>;
1334
+ if (contentType.includes('multipart/form-data')) {
1335
+ isMultipart = true;
1336
+ try {
1337
+ formData = await req.formData();
1338
+ } catch {
1339
+ formData = null;
1340
+ }
1341
+ } else if (contentType.includes('application/json')) {
1342
+ try {
1343
+ const parsed = await req.json();
1344
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1345
+ jsonBody = parsed as Record<string, unknown>;
1346
+ }
1347
+ } catch {
1348
+ jsonBody = null;
1094
1349
  }
1095
- } catch {
1096
- jsonBody = null;
1097
1350
  }
1098
1351
  }
1099
- }
1100
1352
 
1101
- for (const param of sortedParams) {
1102
- switch (param.type) {
1103
- case ParamType.PATH:
1353
+ for (const param of sortedParams) {
1354
+ switch (param.type) {
1355
+ case ParamType.PATH:
1104
1356
  // Use req.params from BunRequest (natively populated by Bun routes API)
1105
- args[param.index] = param.name
1106
- ? (req.params as Record<string, string>)[param.name]
1107
- : undefined;
1108
- break;
1357
+ args[param.index] = param.name
1358
+ ? (req.params as Record<string, string>)[param.name]
1359
+ : undefined;
1360
+ break;
1109
1361
 
1110
- case ParamType.QUERY:
1111
- args[param.index] = param.name ? queryParams[param.name] : undefined;
1112
- break;
1362
+ case ParamType.QUERY:
1363
+ args[param.index] = param.name ? queryParams[param.name] : undefined;
1364
+ break;
1113
1365
 
1114
- case ParamType.BODY:
1115
- try {
1116
- args[param.index] = await req.json();
1117
- } catch {
1118
- args[param.index] = undefined;
1119
- }
1120
- break;
1366
+ case ParamType.BODY:
1367
+ try {
1368
+ args[param.index] = await req.json();
1369
+ } catch {
1370
+ args[param.index] = undefined;
1371
+ }
1372
+ break;
1121
1373
 
1122
- case ParamType.HEADER:
1123
- args[param.index] = param.name ? req.headers.get(param.name) : undefined;
1124
- break;
1374
+ case ParamType.HEADER:
1375
+ args[param.index] = param.name ? req.headers.get(param.name) : undefined;
1376
+ break;
1125
1377
 
1126
- case ParamType.COOKIE:
1127
- args[param.index] = param.name ? req.cookies.get(param.name) ?? undefined : undefined;
1128
- break;
1378
+ case ParamType.COOKIE:
1379
+ args[param.index] = param.name ? req.cookies.get(param.name) ?? undefined : undefined;
1380
+ break;
1129
1381
 
1130
- case ParamType.REQUEST:
1131
- args[param.index] = req;
1132
- break;
1382
+ case ParamType.REQUEST:
1383
+ args[param.index] = req;
1384
+ break;
1133
1385
 
1134
- case ParamType.RESPONSE:
1386
+ case ParamType.RESPONSE:
1135
1387
  // For now, we don't support direct response manipulation
1136
- args[param.index] = undefined;
1137
- break;
1388
+ args[param.index] = undefined;
1389
+ break;
1138
1390
 
1139
- case ParamType.FILE: {
1140
- let file: OneBunFile | undefined;
1391
+ case ParamType.FILE: {
1392
+ let file: OneBunFile | undefined;
1141
1393
 
1142
- if (isMultipart && formData && param.name) {
1143
- const entry = formData.get(param.name);
1144
- if (entry instanceof File) {
1145
- file = new OneBunFile(entry);
1394
+ if (isMultipart && formData && param.name) {
1395
+ const entry = formData.get(param.name);
1396
+ if (entry instanceof File) {
1397
+ file = new OneBunFile(entry);
1398
+ }
1399
+ } else if (jsonBody && param.name) {
1400
+ file = extractFileFromJson(jsonBody, param.name);
1146
1401
  }
1147
- } else if (jsonBody && param.name) {
1148
- file = extractFileFromJson(jsonBody, param.name);
1149
- }
1150
1402
 
1151
- if (file && param.fileOptions) {
1152
- validateFile(file, param.fileOptions, param.name);
1153
- }
1403
+ if (file && param.fileOptions) {
1404
+ validateFile(file, param.fileOptions, param.name);
1405
+ }
1154
1406
 
1155
- args[param.index] = file;
1156
- break;
1157
- }
1407
+ args[param.index] = file;
1408
+ break;
1409
+ }
1158
1410
 
1159
- case ParamType.FILES: {
1160
- let files: OneBunFile[] = [];
1411
+ case ParamType.FILES: {
1412
+ let files: OneBunFile[] = [];
1161
1413
 
1162
- if (isMultipart && formData) {
1163
- if (param.name) {
1414
+ if (isMultipart && formData) {
1415
+ if (param.name) {
1164
1416
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1165
- const entries: any[] = formData.getAll(param.name);
1166
- files = entries
1167
- .filter((entry: unknown): entry is File => entry instanceof File)
1168
- .map((f: File) => new OneBunFile(f));
1169
- } else {
1417
+ const entries: any[] = formData.getAll(param.name);
1418
+ files = entries
1419
+ .filter((entry: unknown): entry is File => entry instanceof File)
1420
+ .map((f: File) => new OneBunFile(f));
1421
+ } else {
1170
1422
  // Get all files from all fields
1171
- for (const [, value] of formData.entries()) {
1172
- if (value instanceof File) {
1173
- files.push(new OneBunFile(value));
1423
+ for (const [, value] of formData.entries()) {
1424
+ if (value instanceof File) {
1425
+ files.push(new OneBunFile(value));
1426
+ }
1174
1427
  }
1175
1428
  }
1176
- }
1177
- } else if (jsonBody) {
1178
- if (param.name) {
1179
- const fieldValue = jsonBody[param.name];
1180
- if (Array.isArray(fieldValue)) {
1181
- files = fieldValue
1182
- .map((item) => extractFileFromJsonValue(item))
1183
- .filter((f): f is OneBunFile => f !== undefined);
1184
- }
1185
- } else {
1429
+ } else if (jsonBody) {
1430
+ if (param.name) {
1431
+ const fieldValue = jsonBody[param.name];
1432
+ if (Array.isArray(fieldValue)) {
1433
+ files = fieldValue
1434
+ .map((item) => extractFileFromJsonValue(item))
1435
+ .filter((f): f is OneBunFile => f !== undefined);
1436
+ }
1437
+ } else {
1186
1438
  // Extract all file-like values from JSON
1187
- for (const [, value] of Object.entries(jsonBody)) {
1188
- const file = extractFileFromJsonValue(value);
1189
- if (file) {
1190
- files.push(file);
1439
+ for (const [, value] of Object.entries(jsonBody)) {
1440
+ const file = extractFileFromJsonValue(value);
1441
+ if (file) {
1442
+ files.push(file);
1443
+ }
1191
1444
  }
1192
1445
  }
1193
1446
  }
1194
- }
1195
1447
 
1196
- // Validate maxCount
1197
- if (param.fileOptions?.maxCount !== undefined && files.length > param.fileOptions.maxCount) {
1198
- throw new Error(
1199
- `Too many files for "${param.name || 'upload'}". Got ${files.length}, max is ${param.fileOptions.maxCount}`,
1200
- );
1201
- }
1448
+ // Validate maxCount
1449
+ if (param.fileOptions?.maxCount !== undefined && files.length > param.fileOptions.maxCount) {
1450
+ throw new HttpException(
1451
+ HttpStatusCode.BAD_REQUEST,
1452
+ `Too many files for "${param.name || 'upload'}". Got ${files.length}, max is ${param.fileOptions.maxCount}`,
1453
+ );
1454
+ }
1202
1455
 
1203
- // Validate each file
1204
- if (param.fileOptions) {
1205
- for (const file of files) {
1206
- validateFile(file, param.fileOptions, param.name);
1456
+ // Validate each file
1457
+ if (param.fileOptions) {
1458
+ for (const file of files) {
1459
+ validateFile(file, param.fileOptions, param.name);
1460
+ }
1207
1461
  }
1208
- }
1209
1462
 
1210
- args[param.index] = files;
1211
- break;
1212
- }
1463
+ args[param.index] = files;
1464
+ break;
1465
+ }
1213
1466
 
1214
- case ParamType.FORM_FIELD: {
1215
- let value: string | undefined;
1467
+ case ParamType.FORM_FIELD: {
1468
+ let value: string | undefined;
1216
1469
 
1217
- if (isMultipart && formData && param.name) {
1218
- const entry = formData.get(param.name);
1219
- if (typeof entry === 'string') {
1220
- value = entry;
1221
- }
1222
- } else if (jsonBody && param.name) {
1223
- const jsonValue = jsonBody[param.name];
1224
- if (jsonValue !== undefined && jsonValue !== null) {
1225
- value = String(jsonValue);
1470
+ if (isMultipart && formData && param.name) {
1471
+ const entry = formData.get(param.name);
1472
+ if (typeof entry === 'string') {
1473
+ value = entry;
1474
+ }
1475
+ } else if (jsonBody && param.name) {
1476
+ const jsonValue = jsonBody[param.name];
1477
+ if (jsonValue !== undefined && jsonValue !== null) {
1478
+ value = String(jsonValue);
1479
+ }
1226
1480
  }
1481
+
1482
+ args[param.index] = value;
1483
+ break;
1227
1484
  }
1228
1485
 
1229
- args[param.index] = value;
1230
- break;
1486
+ default:
1487
+ args[param.index] = undefined;
1231
1488
  }
1232
1489
 
1233
- default:
1234
- args[param.index] = undefined;
1235
- }
1236
-
1237
- // Validate parameter if required
1238
- if (param.isRequired && (args[param.index] === undefined || args[param.index] === null)) {
1239
- throw new Error(`Required parameter ${param.name || param.index} is missing`);
1240
- }
1490
+ // Validate parameter if required
1491
+ if (param.isRequired && (args[param.index] === undefined || args[param.index] === null)) {
1492
+ throw new HttpException(HttpStatusCode.BAD_REQUEST, `Required parameter ${param.name || param.index} is missing`);
1493
+ }
1241
1494
 
1242
- // For FILES type, also check for empty array when required
1243
- if (
1244
- param.isRequired &&
1495
+ // For FILES type, also check for empty array when required
1496
+ if (
1497
+ param.isRequired &&
1245
1498
  param.type === ParamType.FILES &&
1246
1499
  Array.isArray(args[param.index]) &&
1247
1500
  (args[param.index] as unknown[]).length === 0
1248
- ) {
1249
- throw new Error(`Required parameter ${param.name || param.index} is missing`);
1250
- }
1501
+ ) {
1502
+ throw new HttpException(HttpStatusCode.BAD_REQUEST, `Required parameter ${param.name || param.index} is missing`);
1503
+ }
1251
1504
 
1252
- // Apply arktype schema validation if provided
1253
- if (param.schema && args[param.index] !== undefined) {
1254
- try {
1255
- args[param.index] = validateOrThrow(param.schema, args[param.index]);
1256
- } catch (error) {
1257
- const errorMessage =
1258
- error instanceof Error ? error.message : String(error);
1259
- throw new Error(
1260
- `Parameter ${param.name || param.index} validation failed: ${errorMessage}`,
1261
- );
1505
+ // Apply arktype schema validation if provided
1506
+ if (param.schema && args[param.index] !== undefined) {
1507
+ try {
1508
+ args[param.index] = validateOrThrow(param.schema, args[param.index]);
1509
+ } catch (error) {
1510
+ const errorMessage =
1511
+ error instanceof Error ? error.message : String(error);
1512
+ throw new HttpException(
1513
+ HttpStatusCode.BAD_REQUEST,
1514
+ `Parameter ${param.name || param.index} validation failed: ${errorMessage}`,
1515
+ );
1516
+ }
1262
1517
  }
1263
1518
  }
1264
- }
1265
-
1266
- try {
1267
1519
  // Call handler with injected parameters
1268
1520
  const result = await route.handler(...args);
1269
1521
 
@@ -1378,31 +1630,25 @@ export class OneBunApplication {
1378
1630
  },
1379
1631
  });
1380
1632
  } catch (error) {
1381
- // Convert any thrown errors to standardized error response
1382
- let errorResponse: ApiResponse<never>;
1633
+ // Run through exception filters (route controller → global), then default
1634
+ const filters = route.filters ?? [];
1635
+
1636
+ if (filters.length > 0) {
1637
+ const guardCtx = new HttpExecutionContextImpl(
1638
+ req,
1639
+ route.handlerName ?? '',
1640
+ route.controller.constructor.name,
1641
+ );
1383
1642
 
1384
- if (error instanceof OneBunBaseError) {
1385
- errorResponse = error.toErrorResponse();
1386
- } else {
1387
- const message = error instanceof Error ? error.message : String(error);
1388
- const code =
1389
- error instanceof Error && 'code' in error
1390
- ? Number((error as { code: unknown }).code)
1391
- : HttpStatusCode.INTERNAL_SERVER_ERROR;
1392
- errorResponse = createErrorResponse(message, code, message, undefined, {
1393
- originalErrorName: error instanceof Error ? error.name : 'UnknownError',
1394
- stack: error instanceof Error ? error.stack : undefined,
1395
- });
1643
+ // Last filter wins (route-level filters were appended last and take highest priority)
1644
+ return await filters[filters.length - 1].catch(error, guardCtx);
1396
1645
  }
1397
1646
 
1398
- // Always return 200 for consistency with API response format
1399
- return new Response(JSON.stringify(errorResponse), {
1400
- status: HttpStatusCode.OK,
1401
- headers: {
1402
- // eslint-disable-next-line @typescript-eslint/naming-convention
1403
- 'Content-Type': 'application/json',
1404
- },
1405
- });
1647
+ return await defaultExceptionFilter.catch(error, new HttpExecutionContextImpl(
1648
+ req,
1649
+ route.handlerName ?? '',
1650
+ route.controller.constructor.name,
1651
+ ));
1406
1652
  }
1407
1653
  }
1408
1654
 
@@ -1481,6 +1727,7 @@ export class OneBunApplication {
1481
1727
  await this.queueService.stop();
1482
1728
  this.queueService = null;
1483
1729
  }
1730
+ this.queueServiceProxy?.setDelegate(null);
1484
1731
 
1485
1732
  // Disconnect queue adapter
1486
1733
  if (this.queueAdapter) {
@@ -1542,43 +1789,57 @@ export class OneBunApplication {
1542
1789
  }
1543
1790
 
1544
1791
  // Create the appropriate adapter
1545
- const adapterType = queueOptions?.adapter ?? 'memory';
1546
-
1547
- if (adapterType === 'memory') {
1548
- this.queueAdapter = new InMemoryQueueAdapter();
1549
- this.logger.info('Queue system initialized with in-memory adapter');
1550
- } else if (adapterType === 'redis') {
1551
- const redisOptions = queueOptions?.redis ?? {};
1552
- if (redisOptions.useSharedProvider !== false) {
1553
- // Use shared Redis provider
1554
- this.queueAdapter = new RedisQueueAdapter({
1555
- useSharedClient: true,
1556
- keyPrefix: redisOptions.prefix ?? 'onebun:queue:',
1557
- });
1558
- this.logger.info('Queue system initialized with Redis adapter (shared provider)');
1559
- } else if (redisOptions.url) {
1560
- // Create dedicated Redis connection
1561
- this.queueAdapter = new RedisQueueAdapter({
1562
- useSharedClient: false,
1563
- url: redisOptions.url,
1564
- keyPrefix: redisOptions.prefix ?? 'onebun:queue:',
1565
- });
1566
- this.logger.info('Queue system initialized with Redis adapter (dedicated connection)');
1792
+ const adapterOpt = queueOptions?.adapter ?? 'memory';
1793
+
1794
+ if (typeof adapterOpt === 'function') {
1795
+ // Custom adapter constructor (e.g. NATS JetStream)
1796
+ const adapterCtor = adapterOpt;
1797
+ this.queueAdapter = new adapterCtor(queueOptions?.options);
1798
+ await this.queueAdapter.connect();
1799
+ this.logger.info(`Queue system initialized with custom adapter: ${this.queueAdapter.name}`);
1800
+ } else {
1801
+ const adapterType = adapterOpt;
1802
+ if (adapterType === 'memory') {
1803
+ this.queueAdapter = new InMemoryQueueAdapter();
1804
+ this.logger.info('Queue system initialized with in-memory adapter');
1805
+ } else if (adapterType === 'redis') {
1806
+ const redisOptions = queueOptions?.redis ?? {};
1807
+ if (redisOptions.useSharedProvider !== false) {
1808
+ // Use shared Redis provider
1809
+ this.queueAdapter = new RedisQueueAdapter({
1810
+ useSharedClient: true,
1811
+ keyPrefix: redisOptions.prefix ?? 'onebun:queue:',
1812
+ });
1813
+ this.logger.info('Queue system initialized with Redis adapter (shared provider)');
1814
+ } else if (redisOptions.url) {
1815
+ // Create dedicated Redis connection
1816
+ this.queueAdapter = new RedisQueueAdapter({
1817
+ useSharedClient: false,
1818
+ url: redisOptions.url,
1819
+ keyPrefix: redisOptions.prefix ?? 'onebun:queue:',
1820
+ });
1821
+ this.logger.info('Queue system initialized with Redis adapter (dedicated connection)');
1822
+ } else {
1823
+ throw new Error('Redis queue adapter requires either useSharedProvider: true or a url');
1824
+ }
1567
1825
  } else {
1568
- throw new Error('Redis queue adapter requires either useSharedProvider: true or a url');
1826
+ throw new Error(`Unknown queue adapter type: ${adapterType}`);
1569
1827
  }
1570
- } else {
1571
- throw new Error(`Unknown queue adapter type: ${adapterType}`);
1572
- }
1573
1828
 
1574
- // Connect the adapter
1575
- await this.queueAdapter.connect();
1829
+ // Connect the adapter
1830
+ await this.queueAdapter.connect();
1831
+ }
1576
1832
 
1577
1833
  // Create queue service with config
1578
- this.queueService = new QueueService({
1579
- adapter: adapterType,
1580
- options: queueOptions?.redis,
1581
- });
1834
+ const queueServiceConfig: QueueConfig = {
1835
+ adapter:
1836
+ typeof adapterOpt === 'function' ? this.queueAdapter.type : adapterOpt,
1837
+ options:
1838
+ typeof adapterOpt === 'function'
1839
+ ? (queueOptions?.options as Record<string, unknown> | undefined)
1840
+ : queueOptions?.redis,
1841
+ };
1842
+ this.queueService = new QueueService(queueServiceConfig);
1582
1843
 
1583
1844
  // Initialize with the adapter
1584
1845
  await this.queueService.initialize(this.queueAdapter);
@@ -1603,6 +1864,9 @@ export class OneBunApplication {
1603
1864
  // Start the queue service
1604
1865
  await this.queueService.start();
1605
1866
  this.logger.info('Queue service started');
1867
+
1868
+ // Wire the real QueueService into the DI proxy so injected consumers use it
1869
+ this.queueServiceProxy?.setDelegate(this.queueService);
1606
1870
  }
1607
1871
 
1608
1872
  /**
@@ -1723,12 +1987,31 @@ export class OneBunApplication {
1723
1987
  return this.logger;
1724
1988
  }
1725
1989
 
1990
+ /**
1991
+ * Get the actual port the server is listening on.
1992
+ * When `port: 0` is passed, the OS assigns a free port — use this method
1993
+ * to obtain the real port after `start()`.
1994
+ * @returns Actual listening port, or the configured port if not yet started
1995
+ */
1996
+ getPort(): number {
1997
+ return this.server?.port ?? this.options.port ?? 3000;
1998
+ }
1999
+
2000
+ /**
2001
+ * Get the underlying Bun server instance.
2002
+ * Use this to dispatch requests directly (bypassing the global `fetch`) e.g. in tests.
2003
+ * @internal
2004
+ */
2005
+ getServer(): ReturnType<typeof Bun.serve> | null {
2006
+ return this.server;
2007
+ }
2008
+
1726
2009
  /**
1727
2010
  * Get the HTTP URL where the application is listening
1728
2011
  * @returns The HTTP URL
1729
2012
  */
1730
2013
  getHttpUrl(): string {
1731
- return `http://${this.options.host}:${this.options.port}`;
2014
+ return `http://${this.options.host ?? '0.0.0.0'}:${this.getPort()}`;
1732
2015
  }
1733
2016
 
1734
2017
  /**