@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.
- package/package.json +6 -6
- package/src/application/application.test.ts +350 -7
- package/src/application/application.ts +537 -254
- package/src/application/multi-service-application.test.ts +15 -0
- package/src/application/multi-service-application.ts +2 -0
- package/src/application/multi-service.types.ts +7 -1
- package/src/decorators/decorators.ts +213 -0
- package/src/docs-examples.test.ts +386 -3
- package/src/exception-filters/exception-filters.test.ts +172 -0
- package/src/exception-filters/exception-filters.ts +129 -0
- package/src/exception-filters/http-exception.ts +22 -0
- package/src/exception-filters/index.ts +2 -0
- package/src/file/onebun-file.ts +8 -2
- package/src/http-guards/http-guards.test.ts +230 -0
- package/src/http-guards/http-guards.ts +173 -0
- package/src/http-guards/index.ts +1 -0
- package/src/index.ts +10 -0
- package/src/module/module.test.ts +78 -0
- package/src/module/module.ts +55 -7
- package/src/queue/docs-examples.test.ts +72 -12
- package/src/queue/index.ts +4 -0
- package/src/queue/queue-service-proxy.test.ts +82 -0
- package/src/queue/queue-service-proxy.ts +114 -0
- package/src/queue/types.ts +2 -2
- package/src/security/cors-middleware.ts +212 -0
- package/src/security/index.ts +19 -0
- package/src/security/rate-limit-middleware.ts +276 -0
- package/src/security/security-headers-middleware.ts +188 -0
- package/src/security/security.test.ts +285 -0
- package/src/testing/index.ts +1 -0
- package/src/testing/testing-module.test.ts +199 -0
- package/src/testing/testing-module.ts +252 -0
- package/src/types.ts +153 -3
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import
|
|
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 {
|
|
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(
|
|
117
|
-
if (
|
|
118
|
-
return
|
|
145
|
+
function normalizePath(pathStr: string): string {
|
|
146
|
+
if (pathStr === '/' || pathStr.length <= 1) {
|
|
147
|
+
return pathStr;
|
|
119
148
|
}
|
|
120
149
|
|
|
121
|
-
return
|
|
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>>(
|
|
420
|
+
getConfigValue<P extends DeepPaths<OneBunAppConfig>>(pathKey: P): DeepValue<OneBunAppConfig, P>;
|
|
368
421
|
/** Fallback for dynamic paths */
|
|
369
|
-
getConfigValue<T = unknown>(
|
|
370
|
-
getConfigValue(
|
|
371
|
-
return this.getConfig().get(
|
|
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
|
-
//
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
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
|
|
730
|
-
const
|
|
731
|
-
|
|
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
|
|
924
|
-
const isSocketIoPath = socketioEnabled &&
|
|
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
|
-
|
|
1301
|
+
const args: unknown[] = [];
|
|
1051
1302
|
|
|
1052
|
-
|
|
1053
|
-
|
|
1303
|
+
// Sort params by index to ensure correct order
|
|
1304
|
+
const sortedParams = [...(route.params || [])].sort((a, b) => a.index - b.index);
|
|
1054
1305
|
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
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
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
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
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
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
|
-
|
|
1080
|
-
|
|
1331
|
+
if (needsFileData) {
|
|
1332
|
+
const contentType = req.headers.get('content-type') || '';
|
|
1081
1333
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1357
|
+
args[param.index] = param.name
|
|
1358
|
+
? (req.params as Record<string, string>)[param.name]
|
|
1359
|
+
: undefined;
|
|
1360
|
+
break;
|
|
1109
1361
|
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1362
|
+
case ParamType.QUERY:
|
|
1363
|
+
args[param.index] = param.name ? queryParams[param.name] : undefined;
|
|
1364
|
+
break;
|
|
1113
1365
|
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1374
|
+
case ParamType.HEADER:
|
|
1375
|
+
args[param.index] = param.name ? req.headers.get(param.name) : undefined;
|
|
1376
|
+
break;
|
|
1125
1377
|
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1378
|
+
case ParamType.COOKIE:
|
|
1379
|
+
args[param.index] = param.name ? req.cookies.get(param.name) ?? undefined : undefined;
|
|
1380
|
+
break;
|
|
1129
1381
|
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1382
|
+
case ParamType.REQUEST:
|
|
1383
|
+
args[param.index] = req;
|
|
1384
|
+
break;
|
|
1133
1385
|
|
|
1134
|
-
|
|
1386
|
+
case ParamType.RESPONSE:
|
|
1135
1387
|
// For now, we don't support direct response manipulation
|
|
1136
|
-
|
|
1137
|
-
|
|
1388
|
+
args[param.index] = undefined;
|
|
1389
|
+
break;
|
|
1138
1390
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1391
|
+
case ParamType.FILE: {
|
|
1392
|
+
let file: OneBunFile | undefined;
|
|
1141
1393
|
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1403
|
+
if (file && param.fileOptions) {
|
|
1404
|
+
validateFile(file, param.fileOptions, param.name);
|
|
1405
|
+
}
|
|
1154
1406
|
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1407
|
+
args[param.index] = file;
|
|
1408
|
+
break;
|
|
1409
|
+
}
|
|
1158
1410
|
|
|
1159
|
-
|
|
1160
|
-
|
|
1411
|
+
case ParamType.FILES: {
|
|
1412
|
+
let files: OneBunFile[] = [];
|
|
1161
1413
|
|
|
1162
|
-
|
|
1163
|
-
|
|
1414
|
+
if (isMultipart && formData) {
|
|
1415
|
+
if (param.name) {
|
|
1164
1416
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
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
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
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
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1463
|
+
args[param.index] = files;
|
|
1464
|
+
break;
|
|
1465
|
+
}
|
|
1213
1466
|
|
|
1214
|
-
|
|
1215
|
-
|
|
1467
|
+
case ParamType.FORM_FIELD: {
|
|
1468
|
+
let value: string | undefined;
|
|
1216
1469
|
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
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
|
-
|
|
1230
|
-
|
|
1486
|
+
default:
|
|
1487
|
+
args[param.index] = undefined;
|
|
1231
1488
|
}
|
|
1232
1489
|
|
|
1233
|
-
|
|
1234
|
-
|
|
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
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
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
|
-
|
|
1250
|
-
|
|
1501
|
+
) {
|
|
1502
|
+
throw new HttpException(HttpStatusCode.BAD_REQUEST, `Required parameter ${param.name || param.index} is missing`);
|
|
1503
|
+
}
|
|
1251
1504
|
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
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
|
-
//
|
|
1382
|
-
|
|
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
|
-
|
|
1385
|
-
|
|
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
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
|
1546
|
-
|
|
1547
|
-
if (
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
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(
|
|
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
|
-
|
|
1575
|
-
|
|
1829
|
+
// Connect the adapter
|
|
1830
|
+
await this.queueAdapter.connect();
|
|
1831
|
+
}
|
|
1576
1832
|
|
|
1577
1833
|
// Create queue service with config
|
|
1578
|
-
|
|
1579
|
-
adapter:
|
|
1580
|
-
|
|
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.
|
|
2014
|
+
return `http://${this.options.host ?? '0.0.0.0'}:${this.getPort()}`;
|
|
1732
2015
|
}
|
|
1733
2016
|
|
|
1734
2017
|
/**
|