@onebun/core 0.2.7 → 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.
@@ -23,21 +23,24 @@ import {
23
23
  type SyncLogger,
24
24
  } from '@onebun/logger';
25
25
  import {
26
- type ApiResponse,
27
26
  createErrorResponse,
28
27
  createSuccessResponse,
29
28
  HttpStatusCode,
30
- OneBunBaseError,
31
29
  } from '@onebun/requests';
32
30
  import { makeTraceService, TraceService } from '@onebun/trace';
33
31
 
34
32
  import {
33
+ getControllerFilters,
34
+ getControllerGuards,
35
35
  getControllerMetadata,
36
36
  getControllerMiddleware,
37
37
  getSseMetadata,
38
38
  type SseDecoratorOptions,
39
39
  } from '../decorators/decorators';
40
+ import { defaultExceptionFilter, type ExceptionFilter } from '../exception-filters/exception-filters';
41
+ import { HttpException } from '../exception-filters/http-exception';
40
42
  import { OneBunFile, validateFile } from '../file/onebun-file';
43
+ import { executeHttpGuards, HttpExecutionContextImpl } from '../http-guards/http-guards';
41
44
  import {
42
45
  NotInitializedConfig,
43
46
  type IConfig,
@@ -62,6 +65,9 @@ import { InMemoryQueueAdapter } from '../queue/adapters/memory.adapter';
62
65
  import { RedisQueueAdapter } from '../queue/adapters/redis.adapter';
63
66
  import { hasQueueDecorators } from '../queue/decorators';
64
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';
65
71
  import {
66
72
  type ApplicationOptions,
67
73
  type HttpMethod,
@@ -498,6 +504,13 @@ export class OneBunApplication {
498
504
  );
499
505
  }
500
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
+
501
514
  // Start metrics collection if enabled
502
515
  if (this.metricsService && this.metricsService.startSystemMetricsCollection) {
503
516
  this.metricsService.startSystemMetricsCollection();
@@ -652,41 +665,65 @@ export class OneBunApplication {
652
665
  try {
653
666
  let response: Response;
654
667
 
655
- // Execute middleware chain if any, then handler
656
- if (routeMeta.middleware && routeMeta.middleware.length > 0) {
657
- const next = async (index: number): Promise<Response> => {
658
- if (index >= routeMeta.middleware!.length) {
659
- 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
+ ),
660
687
  {
661
- handler: boundHandler,
662
- handlerName: routeMeta.handler,
663
- controller,
664
- params: routeMeta.params,
665
- responseSchemas: routeMeta.responseSchemas,
688
+ status: HttpStatusCode.OK,
689
+ headers: {
690
+ // eslint-disable-next-line @typescript-eslint/naming-convention
691
+ 'Content-Type': 'application/json',
692
+ },
666
693
  },
667
- req,
668
- queryParams,
669
694
  );
670
695
  }
696
+ }
671
697
 
672
- const middleware = routeMeta.middleware![index];
673
-
674
- return await middleware(req, () => next(index + 1));
675
- };
676
-
677
- response = await next(0);
678
- } else {
679
- response = await executeHandler(
698
+ return await executeHandler(
680
699
  {
681
700
  handler: boundHandler,
682
701
  handlerName: routeMeta.handler,
683
702
  controller,
684
703
  params: routeMeta.params,
685
704
  responseSchemas: routeMeta.responseSchemas,
705
+ filters: routeMeta.filters,
686
706
  },
687
707
  req,
688
708
  queryParams,
689
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();
690
727
  }
691
728
 
692
729
  const duration = Date.now() - startTime;
@@ -782,10 +819,31 @@ export class OneBunApplication {
782
819
  };
783
820
  }
784
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
+
785
842
  // Application-wide middleware — resolve class constructors via root module DI
786
- const globalMiddlewareClasses = (this.options.middleware as Function[] | undefined) ?? [];
787
- const globalMiddleware: Function[] = globalMiddlewareClasses.length > 0
788
- ? (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) ?? [])
789
847
  : [];
790
848
 
791
849
  // Add routes from controllers
@@ -847,9 +905,23 @@ export class OneBunApplication {
847
905
  ...ctrlMiddleware,
848
906
  ...routeMiddleware,
849
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
+
850
920
  const routeWithMergedMiddleware: RouteMetadata = {
851
921
  ...route,
852
922
  middleware: mergedMiddleware.length > 0 ? mergedMiddleware : undefined,
923
+ guards: mergedGuards.length > 0 ? mergedGuards : undefined,
924
+ filters: mergedFilters.length > 0 ? mergedFilters : undefined,
853
925
  };
854
926
 
855
927
  // Create wrapped handler with full OneBun lifecycle (tracing, metrics, middleware)
@@ -1201,6 +1273,7 @@ export class OneBunApplication {
1201
1273
  controller: Controller;
1202
1274
  params?: ParamMetadata[];
1203
1275
  responseSchemas?: RouteMetadata['responseSchemas'];
1276
+ filters?: ExceptionFilter[];
1204
1277
  },
1205
1278
  req: OneBunRequest,
1206
1279
  queryParams: Record<string, string | string[]>,
@@ -1223,224 +1296,226 @@ export class OneBunApplication {
1223
1296
  return result;
1224
1297
  }
1225
1298
 
1299
+ try {
1226
1300
  // Prepare arguments array based on parameter metadata
1227
- const args: unknown[] = [];
1301
+ const args: unknown[] = [];
1228
1302
 
1229
- // Sort params by index to ensure correct order
1230
- 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);
1231
1305
 
1232
- // Pre-parse body for file upload params (FormData or JSON, cached for all params)
1233
- const needsFileData = sortedParams.some(
1234
- (p) =>
1235
- 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 ||
1236
1310
  p.type === ParamType.FILES ||
1237
1311
  p.type === ParamType.FORM_FIELD,
1238
- );
1312
+ );
1239
1313
 
1240
- // Validate that @Body and file decorators are not used on the same method
1241
- if (needsFileData) {
1242
- const hasBody = sortedParams.some((p) => p.type === ParamType.BODY);
1243
- if (hasBody) {
1244
- throw new Error(
1245
- '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. ' +
1246
1321
  'Both consume the request body. Use file decorators for multipart/base64 uploads.',
1247
- );
1322
+ );
1323
+ }
1248
1324
  }
1249
- }
1250
1325
 
1251
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1252
- let formData: any = null;
1253
- let jsonBody: Record<string, unknown> | null = null;
1254
- 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;
1255
1330
 
1256
- if (needsFileData) {
1257
- const contentType = req.headers.get('content-type') || '';
1331
+ if (needsFileData) {
1332
+ const contentType = req.headers.get('content-type') || '';
1258
1333
 
1259
- if (contentType.includes('multipart/form-data')) {
1260
- isMultipart = true;
1261
- try {
1262
- formData = await req.formData();
1263
- } catch {
1264
- formData = null;
1265
- }
1266
- } else if (contentType.includes('application/json')) {
1267
- try {
1268
- const parsed = await req.json();
1269
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1270
- 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;
1271
1349
  }
1272
- } catch {
1273
- jsonBody = null;
1274
1350
  }
1275
1351
  }
1276
- }
1277
1352
 
1278
- for (const param of sortedParams) {
1279
- switch (param.type) {
1280
- case ParamType.PATH:
1353
+ for (const param of sortedParams) {
1354
+ switch (param.type) {
1355
+ case ParamType.PATH:
1281
1356
  // Use req.params from BunRequest (natively populated by Bun routes API)
1282
- args[param.index] = param.name
1283
- ? (req.params as Record<string, string>)[param.name]
1284
- : undefined;
1285
- break;
1357
+ args[param.index] = param.name
1358
+ ? (req.params as Record<string, string>)[param.name]
1359
+ : undefined;
1360
+ break;
1286
1361
 
1287
- case ParamType.QUERY:
1288
- args[param.index] = param.name ? queryParams[param.name] : undefined;
1289
- break;
1362
+ case ParamType.QUERY:
1363
+ args[param.index] = param.name ? queryParams[param.name] : undefined;
1364
+ break;
1290
1365
 
1291
- case ParamType.BODY:
1292
- try {
1293
- args[param.index] = await req.json();
1294
- } catch {
1295
- args[param.index] = undefined;
1296
- }
1297
- break;
1366
+ case ParamType.BODY:
1367
+ try {
1368
+ args[param.index] = await req.json();
1369
+ } catch {
1370
+ args[param.index] = undefined;
1371
+ }
1372
+ break;
1298
1373
 
1299
- case ParamType.HEADER:
1300
- args[param.index] = param.name ? req.headers.get(param.name) : undefined;
1301
- break;
1374
+ case ParamType.HEADER:
1375
+ args[param.index] = param.name ? req.headers.get(param.name) : undefined;
1376
+ break;
1302
1377
 
1303
- case ParamType.COOKIE:
1304
- args[param.index] = param.name ? req.cookies.get(param.name) ?? undefined : undefined;
1305
- break;
1378
+ case ParamType.COOKIE:
1379
+ args[param.index] = param.name ? req.cookies.get(param.name) ?? undefined : undefined;
1380
+ break;
1306
1381
 
1307
- case ParamType.REQUEST:
1308
- args[param.index] = req;
1309
- break;
1382
+ case ParamType.REQUEST:
1383
+ args[param.index] = req;
1384
+ break;
1310
1385
 
1311
- case ParamType.RESPONSE:
1386
+ case ParamType.RESPONSE:
1312
1387
  // For now, we don't support direct response manipulation
1313
- args[param.index] = undefined;
1314
- break;
1388
+ args[param.index] = undefined;
1389
+ break;
1390
+
1391
+ case ParamType.FILE: {
1392
+ let file: OneBunFile | undefined;
1315
1393
 
1316
- case ParamType.FILE: {
1317
- let file: OneBunFile | undefined;
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);
1401
+ }
1318
1402
 
1319
- if (isMultipart && formData && param.name) {
1320
- const entry = formData.get(param.name);
1321
- if (entry instanceof File) {
1322
- file = new OneBunFile(entry);
1403
+ if (file && param.fileOptions) {
1404
+ validateFile(file, param.fileOptions, param.name);
1323
1405
  }
1324
- } else if (jsonBody && param.name) {
1325
- file = extractFileFromJson(jsonBody, param.name);
1326
- }
1327
1406
 
1328
- if (file && param.fileOptions) {
1329
- validateFile(file, param.fileOptions, param.name);
1407
+ args[param.index] = file;
1408
+ break;
1330
1409
  }
1331
1410
 
1332
- args[param.index] = file;
1333
- break;
1334
- }
1411
+ case ParamType.FILES: {
1412
+ let files: OneBunFile[] = [];
1335
1413
 
1336
- case ParamType.FILES: {
1337
- let files: OneBunFile[] = [];
1338
-
1339
- if (isMultipart && formData) {
1340
- if (param.name) {
1414
+ if (isMultipart && formData) {
1415
+ if (param.name) {
1341
1416
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1342
- const entries: any[] = formData.getAll(param.name);
1343
- files = entries
1344
- .filter((entry: unknown): entry is File => entry instanceof File)
1345
- .map((f: File) => new OneBunFile(f));
1346
- } 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 {
1347
1422
  // Get all files from all fields
1348
- for (const [, value] of formData.entries()) {
1349
- if (value instanceof File) {
1350
- files.push(new OneBunFile(value));
1423
+ for (const [, value] of formData.entries()) {
1424
+ if (value instanceof File) {
1425
+ files.push(new OneBunFile(value));
1426
+ }
1351
1427
  }
1352
1428
  }
1353
- }
1354
- } else if (jsonBody) {
1355
- if (param.name) {
1356
- const fieldValue = jsonBody[param.name];
1357
- if (Array.isArray(fieldValue)) {
1358
- files = fieldValue
1359
- .map((item) => extractFileFromJsonValue(item))
1360
- .filter((f): f is OneBunFile => f !== undefined);
1361
- }
1362
- } 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 {
1363
1438
  // Extract all file-like values from JSON
1364
- for (const [, value] of Object.entries(jsonBody)) {
1365
- const file = extractFileFromJsonValue(value);
1366
- if (file) {
1367
- files.push(file);
1439
+ for (const [, value] of Object.entries(jsonBody)) {
1440
+ const file = extractFileFromJsonValue(value);
1441
+ if (file) {
1442
+ files.push(file);
1443
+ }
1368
1444
  }
1369
1445
  }
1370
1446
  }
1371
- }
1372
1447
 
1373
- // Validate maxCount
1374
- if (param.fileOptions?.maxCount !== undefined && files.length > param.fileOptions.maxCount) {
1375
- throw new Error(
1376
- `Too many files for "${param.name || 'upload'}". Got ${files.length}, max is ${param.fileOptions.maxCount}`,
1377
- );
1378
- }
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
+ }
1379
1455
 
1380
- // Validate each file
1381
- if (param.fileOptions) {
1382
- for (const file of files) {
1383
- 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
+ }
1384
1461
  }
1385
- }
1386
1462
 
1387
- args[param.index] = files;
1388
- break;
1389
- }
1463
+ args[param.index] = files;
1464
+ break;
1465
+ }
1390
1466
 
1391
- case ParamType.FORM_FIELD: {
1392
- let value: string | undefined;
1467
+ case ParamType.FORM_FIELD: {
1468
+ let value: string | undefined;
1393
1469
 
1394
- if (isMultipart && formData && param.name) {
1395
- const entry = formData.get(param.name);
1396
- if (typeof entry === 'string') {
1397
- value = entry;
1398
- }
1399
- } else if (jsonBody && param.name) {
1400
- const jsonValue = jsonBody[param.name];
1401
- if (jsonValue !== undefined && jsonValue !== null) {
1402
- 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
+ }
1403
1480
  }
1481
+
1482
+ args[param.index] = value;
1483
+ break;
1404
1484
  }
1405
1485
 
1406
- args[param.index] = value;
1407
- break;
1486
+ default:
1487
+ args[param.index] = undefined;
1408
1488
  }
1409
1489
 
1410
- default:
1411
- args[param.index] = undefined;
1412
- }
1413
-
1414
- // Validate parameter if required
1415
- if (param.isRequired && (args[param.index] === undefined || args[param.index] === null)) {
1416
- throw new Error(`Required parameter ${param.name || param.index} is missing`);
1417
- }
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
+ }
1418
1494
 
1419
- // For FILES type, also check for empty array when required
1420
- if (
1421
- param.isRequired &&
1495
+ // For FILES type, also check for empty array when required
1496
+ if (
1497
+ param.isRequired &&
1422
1498
  param.type === ParamType.FILES &&
1423
1499
  Array.isArray(args[param.index]) &&
1424
1500
  (args[param.index] as unknown[]).length === 0
1425
- ) {
1426
- throw new Error(`Required parameter ${param.name || param.index} is missing`);
1427
- }
1501
+ ) {
1502
+ throw new HttpException(HttpStatusCode.BAD_REQUEST, `Required parameter ${param.name || param.index} is missing`);
1503
+ }
1428
1504
 
1429
- // Apply arktype schema validation if provided
1430
- if (param.schema && args[param.index] !== undefined) {
1431
- try {
1432
- args[param.index] = validateOrThrow(param.schema, args[param.index]);
1433
- } catch (error) {
1434
- const errorMessage =
1435
- error instanceof Error ? error.message : String(error);
1436
- throw new Error(
1437
- `Parameter ${param.name || param.index} validation failed: ${errorMessage}`,
1438
- );
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
+ }
1439
1517
  }
1440
1518
  }
1441
- }
1442
-
1443
- try {
1444
1519
  // Call handler with injected parameters
1445
1520
  const result = await route.handler(...args);
1446
1521
 
@@ -1555,31 +1630,25 @@ export class OneBunApplication {
1555
1630
  },
1556
1631
  });
1557
1632
  } catch (error) {
1558
- // Convert any thrown errors to standardized error response
1559
- 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
+ );
1560
1642
 
1561
- if (error instanceof OneBunBaseError) {
1562
- errorResponse = error.toErrorResponse();
1563
- } else {
1564
- const message = error instanceof Error ? error.message : String(error);
1565
- const code =
1566
- error instanceof Error && 'code' in error
1567
- ? Number((error as { code: unknown }).code)
1568
- : HttpStatusCode.INTERNAL_SERVER_ERROR;
1569
- errorResponse = createErrorResponse(message, code, message, undefined, {
1570
- originalErrorName: error instanceof Error ? error.name : 'UnknownError',
1571
- stack: error instanceof Error ? error.stack : undefined,
1572
- });
1643
+ // Last filter wins (route-level filters were appended last and take highest priority)
1644
+ return await filters[filters.length - 1].catch(error, guardCtx);
1573
1645
  }
1574
1646
 
1575
- // Always return 200 for consistency with API response format
1576
- return new Response(JSON.stringify(errorResponse), {
1577
- status: HttpStatusCode.OK,
1578
- headers: {
1579
- // eslint-disable-next-line @typescript-eslint/naming-convention
1580
- 'Content-Type': 'application/json',
1581
- },
1582
- });
1647
+ return await defaultExceptionFilter.catch(error, new HttpExecutionContextImpl(
1648
+ req,
1649
+ route.handlerName ?? '',
1650
+ route.controller.constructor.name,
1651
+ ));
1583
1652
  }
1584
1653
  }
1585
1654
 
@@ -1918,12 +1987,31 @@ export class OneBunApplication {
1918
1987
  return this.logger;
1919
1988
  }
1920
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
+
1921
2009
  /**
1922
2010
  * Get the HTTP URL where the application is listening
1923
2011
  * @returns The HTTP URL
1924
2012
  */
1925
2013
  getHttpUrl(): string {
1926
- return `http://${this.options.host}:${this.options.port}`;
2014
+ return `http://${this.options.host ?? '0.0.0.0'}:${this.getPort()}`;
1927
2015
  }
1928
2016
 
1929
2017
  /**