@onebun/core 0.2.5 → 0.2.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onebun/core",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "Core package for OneBun framework - decorators, DI, modules, controllers",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -11,6 +11,12 @@ import {
11
11
  import { register } from 'prom-client';
12
12
 
13
13
  import type { ApplicationOptions } from '../types';
14
+ import type {
15
+ MiddlewareClass,
16
+ OneBunRequest,
17
+ OneBunResponse,
18
+ } from '../types';
19
+ import type { OnModuleConfigure } from '../types';
14
20
 
15
21
  import {
16
22
  Module,
@@ -23,8 +29,12 @@ import {
23
29
  Header,
24
30
  Cookie,
25
31
  Req,
32
+ UseMiddleware,
33
+ Middleware,
26
34
  } from '../decorators/decorators';
27
35
  import { Controller as BaseController } from '../module/controller';
36
+ import { BaseMiddleware } from '../module/middleware';
37
+ import { Service } from '../module/service';
28
38
  import { makeMockLoggerLayer } from '../testing/test-utils';
29
39
 
30
40
  import { OneBunApplication } from './application';
@@ -2562,6 +2572,517 @@ describe('OneBunApplication', () => {
2562
2572
  // Verify they are the same (no duplication due to trailing slash)
2563
2573
  expect(recordedMetrics[0].route).toBe(recordedMetrics[1].route);
2564
2574
  });
2575
+
2576
+ describe('Middleware composition (docs: controllers.md#middleware-execution-order)', () => {
2577
+ test('should run route-level middlewares left-to-right then handler', async () => {
2578
+ const order: string[] = [];
2579
+
2580
+ class MwA extends BaseMiddleware {
2581
+ async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
2582
+ order.push('MwA');
2583
+ const res = await next();
2584
+ order.push('MwA-after');
2585
+
2586
+ return res;
2587
+ }
2588
+ }
2589
+
2590
+ class MwB extends BaseMiddleware {
2591
+ async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
2592
+ order.push('MwB');
2593
+ const res = await next();
2594
+ order.push('MwB-after');
2595
+
2596
+ return res;
2597
+ }
2598
+ }
2599
+
2600
+ @Controller('/api')
2601
+ class ApiController extends BaseController {
2602
+ @Get('/chain')
2603
+ @UseMiddleware(MwA, MwB)
2604
+ getChain() {
2605
+ order.push('handler');
2606
+
2607
+ return this.success({ ok: true });
2608
+ }
2609
+ }
2610
+
2611
+ @Module({ controllers: [ApiController] })
2612
+ class TestModule {}
2613
+
2614
+ const app = createTestApp(TestModule);
2615
+ await app.start();
2616
+
2617
+ const request = new Request('http://localhost:3000/api/chain', { method: 'GET' });
2618
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2619
+ const response = await (mockServer as any).fetchHandler(request);
2620
+ const body = await response.json();
2621
+
2622
+ expect(response.status).toBe(200);
2623
+ expect(body.result).toEqual({ ok: true });
2624
+ expect(order).toEqual(['MwA', 'MwB', 'handler', 'MwB-after', 'MwA-after']);
2625
+ });
2626
+
2627
+ test('should run controller-level then route-level middleware', async () => {
2628
+ const order: string[] = [];
2629
+
2630
+ class CtrlMw extends BaseMiddleware {
2631
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
2632
+ order.push('CtrlMw');
2633
+
2634
+ return await next();
2635
+ }
2636
+ }
2637
+
2638
+ class RouteMw extends BaseMiddleware {
2639
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
2640
+ order.push('RouteMw');
2641
+
2642
+ return await next();
2643
+ }
2644
+ }
2645
+
2646
+ @Controller('/api')
2647
+ @UseMiddleware(CtrlMw)
2648
+ class ApiController extends BaseController {
2649
+ @Get('/dashboard')
2650
+ getDashboard() {
2651
+ order.push('handler');
2652
+
2653
+ return this.success({ stats: {} });
2654
+ }
2655
+
2656
+ @Get('/settings')
2657
+ @UseMiddleware(RouteMw)
2658
+ getSettings() {
2659
+ order.push('handler-settings');
2660
+
2661
+ return this.success({ updated: false });
2662
+ }
2663
+ }
2664
+
2665
+ @Module({ controllers: [ApiController] })
2666
+ class TestModule {}
2667
+
2668
+ const app = createTestApp(TestModule);
2669
+ await app.start();
2670
+
2671
+ order.length = 0;
2672
+ const req1 = new Request('http://localhost:3000/api/dashboard', { method: 'GET' });
2673
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2674
+ const res1 = await (mockServer as any).fetchHandler(req1);
2675
+ expect(res1.status).toBe(200);
2676
+ expect(order).toEqual(['CtrlMw', 'handler']);
2677
+
2678
+ order.length = 0;
2679
+ const req2 = new Request('http://localhost:3000/api/settings', { method: 'GET' });
2680
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2681
+ const res2 = await (mockServer as any).fetchHandler(req2);
2682
+ expect(res2.status).toBe(200);
2683
+ expect(order).toEqual(['CtrlMw', 'RouteMw', 'handler-settings']);
2684
+ });
2685
+
2686
+ test('should run module-level then controller-level then route-level middleware', async () => {
2687
+ const order: string[] = [];
2688
+
2689
+ class ModuleMw extends BaseMiddleware {
2690
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
2691
+ order.push('ModuleMw');
2692
+
2693
+ return await next();
2694
+ }
2695
+ }
2696
+
2697
+ class CtrlMw extends BaseMiddleware {
2698
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
2699
+ order.push('CtrlMw');
2700
+
2701
+ return await next();
2702
+ }
2703
+ }
2704
+
2705
+ class RouteMw extends BaseMiddleware {
2706
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
2707
+ order.push('RouteMw');
2708
+
2709
+ return await next();
2710
+ }
2711
+ }
2712
+
2713
+ @Controller('/admin')
2714
+ @UseMiddleware(CtrlMw)
2715
+ class AdminController extends BaseController {
2716
+ @Get('/users')
2717
+ @UseMiddleware(RouteMw)
2718
+ getUsers() {
2719
+ order.push('handler');
2720
+
2721
+ return this.success({ users: [] });
2722
+ }
2723
+ }
2724
+
2725
+ @Module({ controllers: [AdminController] })
2726
+ class TestModule implements OnModuleConfigure {
2727
+ configureMiddleware(): MiddlewareClass[] {
2728
+ return [ModuleMw];
2729
+ }
2730
+ }
2731
+
2732
+ const app = createTestApp(TestModule);
2733
+ await app.start();
2734
+
2735
+ const request = new Request('http://localhost:3000/admin/users', { method: 'GET' });
2736
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2737
+ const response = await (mockServer as any).fetchHandler(request);
2738
+ const body = await response.json();
2739
+
2740
+ expect(response.status).toBe(200);
2741
+ expect(body.result).toEqual({ users: [] });
2742
+ expect(order).toEqual(['ModuleMw', 'CtrlMw', 'RouteMw', 'handler']);
2743
+ });
2744
+
2745
+ test('should run application-wide then module then controller then route middleware', async () => {
2746
+ const order: string[] = [];
2747
+
2748
+ class GlobalMw extends BaseMiddleware {
2749
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
2750
+ order.push('GlobalMw');
2751
+
2752
+ return await next();
2753
+ }
2754
+ }
2755
+
2756
+ class ModuleMw extends BaseMiddleware {
2757
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
2758
+ order.push('ModuleMw');
2759
+
2760
+ return await next();
2761
+ }
2762
+ }
2763
+
2764
+ class CtrlMw extends BaseMiddleware {
2765
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
2766
+ order.push('CtrlMw');
2767
+
2768
+ return await next();
2769
+ }
2770
+ }
2771
+
2772
+ class RouteMw extends BaseMiddleware {
2773
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
2774
+ order.push('RouteMw');
2775
+
2776
+ return await next();
2777
+ }
2778
+ }
2779
+
2780
+ @Controller('/admin')
2781
+ @UseMiddleware(CtrlMw)
2782
+ class AdminController extends BaseController {
2783
+ @Get('/stats')
2784
+ @UseMiddleware(RouteMw)
2785
+ getStats() {
2786
+ order.push('handler');
2787
+
2788
+ return this.success({ stats: {} });
2789
+ }
2790
+ }
2791
+
2792
+ @Module({ controllers: [AdminController] })
2793
+ class TestModule implements OnModuleConfigure {
2794
+ configureMiddleware(): MiddlewareClass[] {
2795
+ return [ModuleMw];
2796
+ }
2797
+ }
2798
+
2799
+ const app = createTestApp(TestModule, { middleware: [GlobalMw] });
2800
+ await app.start();
2801
+
2802
+ const request = new Request('http://localhost:3000/admin/stats', { method: 'GET' });
2803
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2804
+ const response = await (mockServer as any).fetchHandler(request);
2805
+ const body = await response.json();
2806
+
2807
+ expect(response.status).toBe(200);
2808
+ expect(body.result).toEqual({ stats: {} });
2809
+ expect(order).toEqual(['GlobalMw', 'ModuleMw', 'CtrlMw', 'RouteMw', 'handler']);
2810
+ });
2811
+
2812
+ test('should short-circuit when middleware returns without calling next()', async () => {
2813
+ const order: string[] = [];
2814
+
2815
+ class AuthMw extends BaseMiddleware {
2816
+ async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
2817
+ order.push('AuthMw');
2818
+ const token = req.headers.get('X-Test-Token');
2819
+ if (token !== 'secret') {
2820
+ return new Response(JSON.stringify({ error: 'Unauthorized' }), {
2821
+ status: 403,
2822
+ // eslint-disable-next-line @typescript-eslint/naming-convention
2823
+ headers: { 'Content-Type': 'application/json' },
2824
+ });
2825
+ }
2826
+
2827
+ return await next();
2828
+ }
2829
+ }
2830
+
2831
+ @Controller('/api')
2832
+ class ApiController extends BaseController {
2833
+ @Get('/protected')
2834
+ @UseMiddleware(AuthMw)
2835
+ getProtected() {
2836
+ order.push('handler');
2837
+
2838
+ return { data: 'secret' };
2839
+ }
2840
+ }
2841
+
2842
+ @Module({ controllers: [ApiController] })
2843
+ class TestModule {}
2844
+
2845
+ const app = createTestApp(TestModule);
2846
+ await app.start();
2847
+
2848
+ const request = new Request('http://localhost:3000/api/protected', {
2849
+ method: 'GET',
2850
+ // eslint-disable-next-line @typescript-eslint/naming-convention
2851
+ headers: { 'X-Test-Token': 'wrong' },
2852
+ });
2853
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2854
+ const response = await (mockServer as any).fetchHandler(request);
2855
+ const body = await response.json();
2856
+
2857
+ expect(response.status).toBe(403);
2858
+ expect(body.error).toBe('Unauthorized');
2859
+ expect(order).toEqual(['AuthMw']);
2860
+ });
2861
+
2862
+ test('should allow middleware to modify response after next() (onion model)', async () => {
2863
+ class AddHeaderAfterNextMw extends BaseMiddleware {
2864
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
2865
+ const res = await next();
2866
+ res.headers.set('X-After-Next', 'true');
2867
+
2868
+ return res;
2869
+ }
2870
+ }
2871
+
2872
+ @Controller('/api')
2873
+ class ApiController extends BaseController {
2874
+ @Get('/onion')
2875
+ @UseMiddleware(AddHeaderAfterNextMw)
2876
+ getOnion() {
2877
+ return this.success({ value: 1 });
2878
+ }
2879
+ }
2880
+
2881
+ @Module({ controllers: [ApiController] })
2882
+ class TestModule {}
2883
+
2884
+ const app = createTestApp(TestModule);
2885
+ await app.start();
2886
+
2887
+ const request = new Request('http://localhost:3000/api/onion', { method: 'GET' });
2888
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2889
+ const response = await (mockServer as any).fetchHandler(request);
2890
+ const body = await response.json();
2891
+
2892
+ expect(response.status).toBe(200);
2893
+ expect(body.result).toEqual({ value: 1 });
2894
+ expect(response.headers.get('X-After-Next')).toBe('true');
2895
+ });
2896
+ });
2897
+
2898
+ describe('Middleware DI (service injection)', () => {
2899
+ test('should inject service into application-wide middleware', async () => {
2900
+ @Service()
2901
+ class RootService {
2902
+ getValue(): string {
2903
+ return 'global';
2904
+ }
2905
+ }
2906
+
2907
+ @Middleware()
2908
+ class GlobalMw extends BaseMiddleware {
2909
+ constructor(private readonly svc: RootService) {
2910
+ super();
2911
+ }
2912
+
2913
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
2914
+ const res = await next();
2915
+ res.headers.set('X-Injected-Value', this.svc.getValue());
2916
+
2917
+ return res;
2918
+ }
2919
+ }
2920
+
2921
+ @Controller('/api')
2922
+ class ApiController extends BaseController {
2923
+ @Get('/ping')
2924
+ ping() {
2925
+ return this.success({ pong: true });
2926
+ }
2927
+ }
2928
+
2929
+ @Module({ controllers: [ApiController], providers: [RootService] })
2930
+ class TestModule {}
2931
+
2932
+ const app = createTestApp(TestModule, { middleware: [GlobalMw] });
2933
+ await app.start();
2934
+
2935
+ const request = new Request('http://localhost:3000/api/ping', { method: 'GET' });
2936
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2937
+ const response = await (mockServer as any).fetchHandler(request);
2938
+ expect(response.status).toBe(200);
2939
+ expect(response.headers.get('X-Injected-Value')).toBe('global');
2940
+ });
2941
+
2942
+ test('should inject service into module-level middleware', async () => {
2943
+ @Service()
2944
+ class FeatureService {
2945
+ getValue(): string {
2946
+ return 'module';
2947
+ }
2948
+ }
2949
+
2950
+ @Middleware()
2951
+ class FeatureModuleMw extends BaseMiddleware {
2952
+ constructor(private readonly svc: FeatureService) {
2953
+ super();
2954
+ }
2955
+
2956
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
2957
+ const res = await next();
2958
+ res.headers.set('X-Injected-Value', this.svc.getValue());
2959
+
2960
+ return res;
2961
+ }
2962
+ }
2963
+
2964
+ @Controller('/feature')
2965
+ class FeatureController extends BaseController {
2966
+ @Get('/data')
2967
+ getData() {
2968
+ return this.success({ data: true });
2969
+ }
2970
+ }
2971
+
2972
+ @Module({ controllers: [FeatureController], providers: [FeatureService] })
2973
+ class FeatureModule implements OnModuleConfigure {
2974
+ configureMiddleware(): MiddlewareClass[] {
2975
+ return [FeatureModuleMw];
2976
+ }
2977
+ }
2978
+
2979
+ @Module({ imports: [FeatureModule], controllers: [] })
2980
+ class RootModule {}
2981
+
2982
+ const app = createTestApp(RootModule);
2983
+ await app.start();
2984
+
2985
+ const request = new Request('http://localhost:3000/feature/data', { method: 'GET' });
2986
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2987
+ const response = await (mockServer as any).fetchHandler(request);
2988
+ expect(response.status).toBe(200);
2989
+ expect(response.headers.get('X-Injected-Value')).toBe('module');
2990
+ });
2991
+
2992
+ test('should inject service into controller-level middleware from owner module', async () => {
2993
+ @Service()
2994
+ class FeatureService {
2995
+ getValue(): string {
2996
+ return 'controller';
2997
+ }
2998
+ }
2999
+
3000
+ @Middleware()
3001
+ class FeatureCtrlMw extends BaseMiddleware {
3002
+ constructor(private readonly svc: FeatureService) {
3003
+ super();
3004
+ }
3005
+
3006
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
3007
+ const res = await next();
3008
+ res.headers.set('X-Injected-Value', this.svc.getValue());
3009
+
3010
+ return res;
3011
+ }
3012
+ }
3013
+
3014
+ @Controller('/feature')
3015
+ @UseMiddleware(FeatureCtrlMw)
3016
+ class FeatureController extends BaseController {
3017
+ @Get('/ctrl')
3018
+ getCtrl() {
3019
+ return this.success({ ok: true });
3020
+ }
3021
+ }
3022
+
3023
+ @Module({ controllers: [FeatureController], providers: [FeatureService] })
3024
+ class FeatureModule {}
3025
+
3026
+ @Module({ imports: [FeatureModule], controllers: [] })
3027
+ class RootModule {}
3028
+
3029
+ const app = createTestApp(RootModule);
3030
+ await app.start();
3031
+
3032
+ const request = new Request('http://localhost:3000/feature/ctrl', { method: 'GET' });
3033
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3034
+ const response = await (mockServer as any).fetchHandler(request);
3035
+ expect(response.status).toBe(200);
3036
+ expect(response.headers.get('X-Injected-Value')).toBe('controller');
3037
+ });
3038
+
3039
+ test('should inject service into route-level middleware from owner module', async () => {
3040
+ @Service()
3041
+ class FeatureService {
3042
+ getValue(): string {
3043
+ return 'route';
3044
+ }
3045
+ }
3046
+
3047
+ @Middleware()
3048
+ class FeatureRouteMw extends BaseMiddleware {
3049
+ constructor(private readonly svc: FeatureService) {
3050
+ super();
3051
+ }
3052
+
3053
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
3054
+ const res = await next();
3055
+ res.headers.set('X-Injected-Value', this.svc.getValue());
3056
+
3057
+ return res;
3058
+ }
3059
+ }
3060
+
3061
+ @Controller('/feature')
3062
+ class FeatureController extends BaseController {
3063
+ @Get('/route')
3064
+ @UseMiddleware(FeatureRouteMw)
3065
+ getRoute() {
3066
+ return this.success({ ok: true });
3067
+ }
3068
+ }
3069
+
3070
+ @Module({ controllers: [FeatureController], providers: [FeatureService] })
3071
+ class FeatureModule {}
3072
+
3073
+ @Module({ imports: [FeatureModule], controllers: [] })
3074
+ class RootModule {}
3075
+
3076
+ const app = createTestApp(RootModule);
3077
+ await app.start();
3078
+
3079
+ const request = new Request('http://localhost:3000/feature/route', { method: 'GET' });
3080
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
3081
+ const response = await (mockServer as any).fetchHandler(request);
3082
+ expect(response.status).toBe(200);
3083
+ expect(response.headers.get('X-Injected-Value')).toBe('route');
3084
+ });
3085
+ });
2565
3086
  });
2566
3087
 
2567
3088
  describe('Tracing integration', () => {
@@ -758,10 +758,14 @@ export class OneBunApplication {
758
758
  // Module-level middleware (already resolved bound functions)
759
759
  const moduleMiddleware = this.ensureModule().getModuleMiddleware?.(controllerClass) ?? [];
760
760
 
761
- // Controller-level middleware resolve class constructors via root module DI
761
+ // Resolve controller-level and route-level middleware with the owner module's DI
762
+ const ownerModule =
763
+ this.ensureModule().getOwnerModuleForController?.(controllerClass) ?? this.ensureModule();
764
+
765
+ // Controller-level middleware — resolve via owner module DI
762
766
  const ctrlMiddlewareClasses = getControllerMiddleware(controllerClass);
763
767
  const ctrlMiddleware: Function[] = ctrlMiddlewareClasses.length > 0
764
- ? (this.ensureModule().resolveMiddleware?.(ctrlMiddlewareClasses) ?? [])
768
+ ? (ownerModule.resolveMiddleware?.(ctrlMiddlewareClasses) ?? [])
765
769
  : [];
766
770
 
767
771
  for (const route of controllerMetadata.routes) {
@@ -773,10 +777,10 @@ export class OneBunApplication {
773
777
  controller,
774
778
  );
775
779
 
776
- // Route-level middleware — resolve class constructors via root module DI
780
+ // Route-level middleware — resolve via owner module DI
777
781
  const routeMiddlewareClasses = route.middleware ?? [];
778
782
  const routeMiddleware: Function[] = routeMiddlewareClasses.length > 0
779
- ? (this.ensureModule().resolveMiddleware?.(routeMiddlewareClasses) ?? [])
783
+ ? (ownerModule.resolveMiddleware?.(routeMiddlewareClasses) ?? [])
780
784
  : [];
781
785
 
782
786
  // Merge middleware: global → module → controller → route
@@ -52,6 +52,7 @@ import {
52
52
  Req,
53
53
  Res,
54
54
  UseMiddleware,
55
+ Middleware,
55
56
  Module,
56
57
  getModuleMetadata,
57
58
  ApiResponse,
@@ -301,6 +302,33 @@ describe('decorators', () => {
301
302
  });
302
303
  });
303
304
 
305
+ describe('Middleware decorator', () => {
306
+ test('should emit design:paramtypes for automatic DI', () => {
307
+ @Service()
308
+ class HelperService {
309
+ getValue() {
310
+ return 'ok';
311
+ }
312
+ }
313
+
314
+ @Middleware()
315
+ class TestMiddleware extends BaseMiddleware {
316
+ constructor(private readonly helper: HelperService) {
317
+ super();
318
+ }
319
+
320
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>) {
321
+ return await next();
322
+ }
323
+ }
324
+
325
+ const deps = getConstructorParamTypes(TestMiddleware);
326
+ expect(deps).toBeDefined();
327
+ expect(deps).toHaveLength(1);
328
+ expect(deps?.[0]).toBe(HelperService);
329
+ });
330
+ });
331
+
304
332
  describe('HTTP method decorators', () => {
305
333
  test('should register GET route', () => {
306
334
  @Controller('test')
@@ -240,6 +240,32 @@ export function Inject<T>(serviceType: new (...args: any[]) => T) {
240
240
  };
241
241
  }
242
242
 
243
+ /**
244
+ * Class decorator for middleware. Apply to classes that extend BaseMiddleware so that
245
+ * TypeScript emits design:paramtypes and constructor dependencies are resolved automatically
246
+ * by the framework (no need for @Inject on each parameter). You can still use @Inject when needed.
247
+ *
248
+ * @example
249
+ * ```ts
250
+ * @Middleware()
251
+ * class AuthMiddleware extends BaseMiddleware {
252
+ * constructor(private authService: AuthService) {
253
+ * super();
254
+ * }
255
+ * async use(req, next) { ... }
256
+ * }
257
+ * ```
258
+ */
259
+ // eslint-disable-next-line @typescript-eslint/naming-convention
260
+ export function Middleware(): ClassDecorator {
261
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
262
+ return (target: any): any => {
263
+ injectable()(target);
264
+
265
+ return target;
266
+ };
267
+ }
268
+
243
269
  /**
244
270
  * Register dependencies manually (fallback method)
245
271
  */
@@ -24,7 +24,11 @@ import type {
24
24
  } from '../types';
25
25
 
26
26
 
27
- import { Controller as CtrlDeco, Module } from '../decorators/decorators';
27
+ import {
28
+ Controller as CtrlDeco,
29
+ Middleware,
30
+ Module,
31
+ } from '../decorators/decorators';
28
32
  import { makeMockLoggerLayer } from '../testing/test-utils';
29
33
  import { BaseWebSocketGateway } from '../websocket/ws-base-gateway';
30
34
  import { WebSocketGateway } from '../websocket/ws-decorators';
@@ -685,7 +689,7 @@ describe('OneBunModule', () => {
685
689
 
686
690
  describe('Controller DI with @Inject decorator', () => {
687
691
  const {
688
- Inject, getConstructorParamTypes, Controller: ControllerDecorator, clearGlobalModules,
692
+ Inject: InjectDecorator, getConstructorParamTypes, Controller: ControllerDecorator, clearGlobalModules,
689
693
  } = require('../decorators/decorators');
690
694
  const { Controller: BaseController } = require('./controller');
691
695
  const { clearGlobalServicesRegistry: clearRegistry } = require('./module');
@@ -716,7 +720,7 @@ describe('OneBunModule', () => {
716
720
 
717
721
  // Define controller with @Inject BEFORE @Controller
718
722
  class OriginalController extends BaseController {
719
- constructor(@Inject(SimpleService) private svc: SimpleService) {
723
+ constructor(@InjectDecorator(SimpleService) private svc: SimpleService) {
720
724
  super();
721
725
  }
722
726
  }
@@ -1189,6 +1193,55 @@ describe('OneBunModule', () => {
1189
1193
  });
1190
1194
  });
1191
1195
 
1196
+ describe('Middleware with service injection', () => {
1197
+ test('should resolve middleware with injected service and use it in use()', async () => {
1198
+ @Service()
1199
+ class HelperService {
1200
+ getValue(): string {
1201
+ return 'injected';
1202
+ }
1203
+ }
1204
+
1205
+ @Middleware()
1206
+ class MiddlewareWithService extends BaseMiddleware {
1207
+ constructor(private readonly helper: HelperService) {
1208
+ super();
1209
+ }
1210
+
1211
+ async use(_req: OneBunRequest, next: () => Promise<OneBunResponse>): Promise<OneBunResponse> {
1212
+ const res = await next();
1213
+ res.headers.set('X-Injected-Value', this.helper.getValue());
1214
+
1215
+ return res;
1216
+ }
1217
+ }
1218
+
1219
+ @CtrlDeco('/test')
1220
+ class TestCtrl extends CtrlBase {}
1221
+
1222
+ @Module({
1223
+ controllers: [TestCtrl],
1224
+ providers: [HelperService],
1225
+ })
1226
+ class TestModule {}
1227
+
1228
+ const module = new OneBunModule(TestModule, mockLoggerLayer);
1229
+ await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
1230
+
1231
+ const resolved = module.resolveMiddleware([MiddlewareWithService]);
1232
+ expect(resolved).toHaveLength(1);
1233
+
1234
+ const mockReq = Object.assign(new Request('http://localhost/'), {
1235
+ params: {},
1236
+ cookies: new Map(),
1237
+ }) as unknown as OneBunRequest;
1238
+ const next = async (): Promise<OneBunResponse> => new Response('ok');
1239
+ const response = await resolved[0](mockReq, next);
1240
+
1241
+ expect(response.headers.get('X-Injected-Value')).toBe('injected');
1242
+ });
1243
+ });
1244
+
1192
1245
  describe('Lifecycle hooks', () => {
1193
1246
  const { clearGlobalModules } = require('../decorators/decorators');
1194
1247
  const { clearGlobalServicesRegistry: clearRegistry, OneBunModule: ModuleClass } = require('./module');
@@ -1375,7 +1428,7 @@ describe('OneBunModule', () => {
1375
1428
  const {
1376
1429
  Controller: ControllerDecorator,
1377
1430
  Get,
1378
- Inject,
1431
+ Inject: InjectDecorator,
1379
1432
  clearGlobalModules,
1380
1433
  } = require('../decorators/decorators');
1381
1434
  const { Controller: BaseController } = require('./controller');
@@ -1404,7 +1457,7 @@ describe('OneBunModule', () => {
1404
1457
  }
1405
1458
 
1406
1459
  class CounterController extends BaseController {
1407
- constructor(@Inject(CounterService) private readonly counterService: CounterService) {
1460
+ constructor(@InjectDecorator(CounterService) private readonly counterService: CounterService) {
1408
1461
  super();
1409
1462
  }
1410
1463
  getCount() {
@@ -1439,7 +1492,7 @@ describe('OneBunModule', () => {
1439
1492
  }
1440
1493
 
1441
1494
  class ChildController extends BaseController {
1442
- constructor(@Inject(ChildService) private readonly childService: ChildService) {
1495
+ constructor(@InjectDecorator(ChildService) private readonly childService: ChildService) {
1443
1496
  super();
1444
1497
  }
1445
1498
  getValue() {
@@ -1486,7 +1539,7 @@ describe('OneBunModule', () => {
1486
1539
  class SharedModule {}
1487
1540
 
1488
1541
  class AppController extends BaseController {
1489
- constructor(@Inject(SharedService) private readonly sharedService: SharedService) {
1542
+ constructor(@InjectDecorator(SharedService) private readonly sharedService: SharedService) {
1490
1543
  super();
1491
1544
  }
1492
1545
  getLabel() {
@@ -974,6 +974,24 @@ export class OneBunModule implements ModuleInstance {
974
974
  return [];
975
975
  }
976
976
 
977
+ /**
978
+ * Get the module instance that owns the given controller.
979
+ * Used to resolve controller-level and route-level middleware with the owner module's DI.
980
+ */
981
+ getOwnerModuleForController(controllerClass: Function): ModuleInstance | undefined {
982
+ if (this.controllers.includes(controllerClass)) {
983
+ return this;
984
+ }
985
+ for (const childModule of this.childModules) {
986
+ const owner = childModule.getOwnerModuleForController(controllerClass);
987
+ if (owner) {
988
+ return owner;
989
+ }
990
+ }
991
+
992
+ return undefined;
993
+ }
994
+
977
995
  /**
978
996
  * Get all controller instances from this module and child modules (recursive).
979
997
  */
package/src/types.ts CHANGED
@@ -163,6 +163,13 @@ export interface ModuleInstance {
163
163
  */
164
164
  getModuleMiddleware?(controllerClass: Function): Function[];
165
165
 
166
+ /**
167
+ * Get the module instance that owns the given controller (the module in whose
168
+ * `controllers` array the controller is declared). Returns this module or a
169
+ * child module, or undefined if the controller is not in this module tree.
170
+ */
171
+ getOwnerModuleForController?(controllerClass: Function): ModuleInstance | undefined;
172
+
166
173
  /**
167
174
  * Resolve middleware class constructors into bound `use()` functions
168
175
  * using this module's DI scope (services + logger + config).