@onebun/core 0.2.2 → 0.2.4

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.2",
3
+ "version": "0.2.4",
4
4
  "description": "Core package for OneBun framework - decorators, DI, modules, controllers",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -693,6 +693,27 @@ describe('Controllers API Documentation Examples', () => {
693
693
 
694
694
  expect(UserController).toBeDefined();
695
695
  });
696
+
697
+ /**
698
+ * @source docs/api/controllers.md#constructor-access
699
+ * Controllers have this.config and this.logger available immediately after super()
700
+ */
701
+ it('should have config and logger available in controller constructor after super()', () => {
702
+ @Service()
703
+ class UserService extends BaseService {}
704
+
705
+ // From docs: Controller with constructor access to config and logger
706
+ @Controller('/users')
707
+ class UserController extends BaseController {
708
+ constructor(private userService: UserService) {
709
+ super();
710
+ // config and logger are available immediately after super()
711
+ // e.g. this.config.get('api.prefix'), this.logger.info('...')
712
+ }
713
+ }
714
+
715
+ expect(UserController).toBeDefined();
716
+ });
696
717
  });
697
718
 
698
719
  describe('Response Methods (docs/api/controllers.md)', () => {
@@ -1393,6 +1414,28 @@ describe('Services API Documentation Examples', () => {
1393
1414
 
1394
1415
  expect(UserService).toBeDefined();
1395
1416
  });
1417
+
1418
+ /**
1419
+ * @source docs/api/services.md#constructor-access
1420
+ * Services have this.config and this.logger available immediately after super()
1421
+ */
1422
+ it('should have config and logger available in service constructor after super()', () => {
1423
+ @Service()
1424
+ class ConfiguredService extends BaseService {
1425
+ readonly configAvailable: boolean;
1426
+ readonly loggerAvailable: boolean;
1427
+
1428
+ constructor() {
1429
+ super();
1430
+ // config and logger are available immediately after super()
1431
+ this.configAvailable = this.config !== undefined;
1432
+ this.loggerAvailable = this.logger !== undefined;
1433
+ }
1434
+ }
1435
+
1436
+ // Verify the class is well-formed — actual DI test is in module.test.ts
1437
+ expect(ConfiguredService).toBeDefined();
1438
+ });
1396
1439
  });
1397
1440
 
1398
1441
  describe('getServiceTag (docs/api/services.md)', () => {
@@ -3234,8 +3277,7 @@ describe('WebSocket Gateway API Documentation (docs/api/websocket.md)', () => {
3234
3277
  class TestGateway extends BaseWebSocketGateway {
3235
3278
  @OnConnect()
3236
3279
  handleConnect(@Client() client: WsClientData) {
3237
- // eslint-disable-next-line no-console
3238
- console.log(`Client ${client.id} connected`);
3280
+ this.logger.info(`Client ${client.id} connected`);
3239
3281
 
3240
3282
  return { event: 'welcome', data: { message: 'Welcome!' } };
3241
3283
  }
@@ -3252,8 +3294,7 @@ describe('WebSocket Gateway API Documentation (docs/api/websocket.md)', () => {
3252
3294
  class TestGateway extends BaseWebSocketGateway {
3253
3295
  @OnDisconnect()
3254
3296
  handleDisconnect(@Client() client: WsClientData) {
3255
- // eslint-disable-next-line no-console
3256
- console.log(`Client ${client.id} disconnected`);
3297
+ this.logger.info(`Client ${client.id} disconnected`);
3257
3298
  }
3258
3299
  }
3259
3300
 
@@ -3682,8 +3723,7 @@ describe('WebSocket Chat Example (docs/examples/websocket-chat.md)', () => {
3682
3723
 
3683
3724
  @OnConnect()
3684
3725
  async handleConnect(@Client() client: WsClientData) {
3685
- // eslint-disable-next-line no-console
3686
- console.log(`Client ${client.id} connected`);
3726
+ this.logger.info(`Client ${client.id} connected`);
3687
3727
 
3688
3728
  return {
3689
3729
  event: 'welcome',
@@ -3697,8 +3737,7 @@ describe('WebSocket Chat Example (docs/examples/websocket-chat.md)', () => {
3697
3737
 
3698
3738
  @OnDisconnect()
3699
3739
  async handleDisconnect(@Client() client: WsClientData) {
3700
- // eslint-disable-next-line no-console
3701
- console.log(`Client ${client.id} disconnected`);
3740
+ this.logger.info(`Client ${client.id} disconnected`);
3702
3741
 
3703
3742
  for (const room of client.rooms) {
3704
3743
  this.emitToRoom(room, 'user:left', {
@@ -3714,8 +3753,7 @@ describe('WebSocket Chat Example (docs/examples/websocket-chat.md)', () => {
3714
3753
  @RoomName() room: string,
3715
3754
  @PatternParams() params: { roomId: string },
3716
3755
  ) {
3717
- // eslint-disable-next-line no-console
3718
- console.log(`Client ${client.id} joining room ${params.roomId}`);
3756
+ this.logger.info(`Client ${client.id} joining room ${params.roomId}`);
3719
3757
 
3720
3758
  await this.joinRoom(client.id, room);
3721
3759
 
@@ -3981,6 +4019,77 @@ describe('WebSocket Chat Example (docs/examples/websocket-chat.md)', () => {
3981
4019
  });
3982
4020
  });
3983
4021
 
4022
+ describe('WebSocket Gateway DI (docs/api/websocket.md#basewebsocketgateway)', () => {
4023
+ /**
4024
+ * @source docs/api/websocket.md#basewebsocketgateway
4025
+ * Gateways receive this.logger and this.config just like controllers.
4026
+ */
4027
+ it('should inject logger and config into WebSocket gateway via module DI', async () => {
4028
+ const effectLib = await import('effect');
4029
+ const moduleMod = await import('./module/module');
4030
+ const testUtils = await import('./testing/test-utils');
4031
+
4032
+ @WebSocketGateway({ path: '/ws' })
4033
+ class TestGateway extends BaseWebSocketGateway {
4034
+ @OnConnect()
4035
+ handleConnect(@Client() client: WsClientData) {
4036
+ this.logger.info(`Client ${client.id} connected`);
4037
+
4038
+ return { event: 'welcome', data: { id: client.id } };
4039
+ }
4040
+
4041
+ getLoggerForTest() {
4042
+ return this.logger;
4043
+ }
4044
+
4045
+ getConfigForTest() {
4046
+ return this.config;
4047
+ }
4048
+ }
4049
+
4050
+ @Module({ controllers: [TestGateway] })
4051
+ class TestModule {}
4052
+
4053
+ const mod = new moduleMod.OneBunModule(TestModule, testUtils.makeMockLoggerLayer());
4054
+ mod.getLayer();
4055
+ await effectLib.Effect.runPromise(mod.setup() as import('effect').Effect.Effect<unknown, never, never>);
4056
+
4057
+ const gateway = mod.getControllerInstance(TestGateway) as unknown as TestGateway;
4058
+ expect(gateway).toBeDefined();
4059
+ expect(gateway.getLoggerForTest()).toBeDefined();
4060
+ expect(typeof gateway.getLoggerForTest().info).toBe('function');
4061
+ expect(typeof gateway.getLoggerForTest().warn).toBe('function');
4062
+ expect(typeof gateway.getLoggerForTest().error).toBe('function');
4063
+ expect(gateway.getConfigForTest()).toBeDefined();
4064
+ expect(typeof gateway.getConfigForTest().get).toBe('function');
4065
+ });
4066
+
4067
+ /**
4068
+ * @source docs/api/websocket.md#constructor-access
4069
+ * Gateways have this.config and this.logger available immediately after super()
4070
+ */
4071
+ it('should have config and logger available in WS gateway constructor after super()', () => {
4072
+ // From docs: Gateway with constructor access to config and logger
4073
+ @WebSocketGateway({ path: '/ws' })
4074
+ class ChatGateway extends BaseWebSocketGateway {
4075
+ readonly configAvailable: boolean;
4076
+
4077
+ constructor() {
4078
+ super();
4079
+ // config and logger are available immediately after super()
4080
+ this.configAvailable = this.config !== undefined;
4081
+ }
4082
+
4083
+ @OnMessage('chat:message')
4084
+ handleMessage(@Client() client: WsClientData, @MessageData() data: unknown) {
4085
+ this.broadcast('chat:message', { userId: client.id, data });
4086
+ }
4087
+ }
4088
+
4089
+ expect(ChatGateway).toBeDefined();
4090
+ });
4091
+ });
4092
+
3984
4093
  // ============================================================================
3985
4094
  // SSE (Server-Sent Events) Documentation Tests
3986
4095
  // ============================================================================
@@ -229,7 +229,7 @@ describe('Controller', () => {
229
229
  expect(mockLogger.child).toHaveBeenCalledWith({ className: 'EmptyController' });
230
230
  });
231
231
 
232
- test('should handle re-initialization attempts', () => {
232
+ test('should handle re-initialization attempts (no-op after first init)', () => {
233
233
  class TestController extends Controller {
234
234
  testMethod() {
235
235
  return 'test';
@@ -242,7 +242,7 @@ describe('Controller', () => {
242
242
  controller.initializeController(mockLogger, mockConfig);
243
243
  expect(mockLogger.debug).toHaveBeenCalledWith('Controller TestController initialized');
244
244
 
245
- // Second initialization (should work but replace values)
245
+ // Second initialization should be a no-op (already initialized)
246
246
  const newMockLogger = {
247
247
  ...mockLogger,
248
248
  child: mock(() => newMockLogger),
@@ -251,7 +251,11 @@ describe('Controller', () => {
251
251
 
252
252
  const newConfig = createMockConfig({ 'newConfig': true });
253
253
  controller.initializeController(newMockLogger, newConfig);
254
- expect(newMockLogger.debug).toHaveBeenCalledWith('Controller TestController initialized');
254
+
255
+ // Should NOT have been called — initializeController is a no-op after first init
256
+ expect(newMockLogger.debug).not.toHaveBeenCalled();
257
+ // Should still have original config
258
+ expect((controller as any).config).toBe(mockConfig);
255
259
  });
256
260
  });
257
261
 
@@ -579,4 +583,108 @@ describe('Controller', () => {
579
583
  expect(text).toBe('');
580
584
  });
581
585
  });
586
+
587
+ describe('Initialization via static init context (constructor)', () => {
588
+ test('should initialize controller via static init context in constructor', () => {
589
+ class TestController extends Controller {
590
+ configAvailableInConstructor = false;
591
+ loggerAvailableInConstructor = false;
592
+
593
+ constructor() {
594
+ super();
595
+ this.configAvailableInConstructor = this.config !== undefined;
596
+ this.loggerAvailableInConstructor = this.logger !== undefined;
597
+ }
598
+ }
599
+
600
+ // Set init context before construction (as the framework does)
601
+ Controller.setInitContext(mockLogger, mockConfig);
602
+ let controller: TestController;
603
+ try {
604
+ controller = new TestController();
605
+ } finally {
606
+ Controller.clearInitContext();
607
+ }
608
+
609
+ expect(controller.configAvailableInConstructor).toBe(true);
610
+ expect(controller.loggerAvailableInConstructor).toBe(true);
611
+ expect((controller as any).config).toBe(mockConfig);
612
+ });
613
+
614
+ test('should allow using config.get() in constructor when init context is set', () => {
615
+ class TestController extends Controller {
616
+ readonly dbHost: string;
617
+
618
+ constructor() {
619
+ super();
620
+ this.dbHost = this.config.get('database.host') as string;
621
+ }
622
+ }
623
+
624
+ Controller.setInitContext(mockLogger, mockConfig);
625
+ let controller: TestController;
626
+ try {
627
+ controller = new TestController();
628
+ } finally {
629
+ Controller.clearInitContext();
630
+ }
631
+
632
+ expect(controller.dbHost).toBe('localhost');
633
+ });
634
+
635
+ test('should create child logger with correct className in constructor', () => {
636
+ class MyCustomController extends Controller {}
637
+
638
+ Controller.setInitContext(mockLogger, mockConfig);
639
+ try {
640
+ new MyCustomController();
641
+ } finally {
642
+ Controller.clearInitContext();
643
+ }
644
+
645
+ expect(mockLogger.child).toHaveBeenCalledWith({ className: 'MyCustomController' });
646
+ });
647
+
648
+ test('should not initialize if no init context is set', () => {
649
+ // Ensure context is clear
650
+ Controller.clearInitContext();
651
+
652
+ class TestController extends Controller {}
653
+ const controller = new TestController();
654
+
655
+ expect((controller as any)._initialized).toBe(false);
656
+ expect((controller as any).logger).toBeUndefined();
657
+ expect((controller as any).config).toBeUndefined();
658
+ });
659
+
660
+ test('initializeController should be a no-op if already initialized via init context', () => {
661
+ class TestController extends Controller {}
662
+
663
+ Controller.setInitContext(mockLogger, mockConfig);
664
+ let controller: TestController;
665
+ try {
666
+ controller = new TestController();
667
+ } finally {
668
+ Controller.clearInitContext();
669
+ }
670
+
671
+ // Call initializeController again — should be a no-op
672
+ const otherLogger = { ...mockLogger, child: mock(() => ({ ...mockLogger })) };
673
+ const otherConfig = createMockConfig({ other: 'config' });
674
+ controller.initializeController(otherLogger, otherConfig);
675
+
676
+ // Should still have original config (no reinit)
677
+ expect((controller as any).config).toBe(mockConfig);
678
+ });
679
+
680
+ test('clearInitContext should prevent subsequent constructors from picking up context', () => {
681
+ Controller.setInitContext(mockLogger, mockConfig);
682
+ Controller.clearInitContext();
683
+
684
+ class TestController extends Controller {}
685
+ const controller = new TestController();
686
+
687
+ expect((controller as any)._initialized).toBe(false);
688
+ });
689
+ });
582
690
  });
@@ -31,7 +31,26 @@ export const DEFAULT_SSE_HEARTBEAT_MS = 30_000;
31
31
  export const DEFAULT_SSE_TIMEOUT = 600;
32
32
 
33
33
  /**
34
- * Base controller class that can be extended to add common functionality
34
+ * Base controller class that can be extended to add common functionality.
35
+ *
36
+ * Controllers extending this class have `this.config` and `this.logger` available
37
+ * immediately after `super()` in the constructor when created through the framework DI.
38
+ * The framework sets an ambient init context before calling the constructor, and
39
+ * the Controller base class reads from it in its constructor.
40
+ *
41
+ * @example
42
+ * ```typescript
43
+ * @Controller('/users')
44
+ * class UserController extends BaseController {
45
+ * private readonly prefix: string;
46
+ *
47
+ * constructor(private userService: UserService) {
48
+ * super();
49
+ * // this.config and this.logger are available here!
50
+ * this.prefix = this.config.get('api.prefix');
51
+ * }
52
+ * }
53
+ * ```
35
54
  */
36
55
  export class Controller {
37
56
  // Store service instances
@@ -42,12 +61,61 @@ export class Controller {
42
61
  protected logger!: SyncLogger;
43
62
  // Configuration instance for accessing environment variables
44
63
  protected config!: IConfig<OneBunAppConfig>;
64
+ // Flag to track initialization status
65
+ private _initialized = false;
66
+
67
+ /**
68
+ * Ambient init context set by the framework before controller construction.
69
+ * This allows the Controller constructor to pick up logger and config
70
+ * so they are available immediately after super() in subclass constructors.
71
+ * @internal
72
+ */
73
+ private static _initContext: { logger: SyncLogger; config: IConfig<OneBunAppConfig> } | null =
74
+ null;
75
+
76
+ /**
77
+ * Set the ambient init context before constructing a controller.
78
+ * Called by the framework (OneBunModule) before `new ControllerClass(...)`.
79
+ * @internal
80
+ */
81
+ static setInitContext(logger: SyncLogger, config: IConfig<OneBunAppConfig>): void {
82
+ Controller._initContext = { logger, config };
83
+ }
45
84
 
46
85
  /**
47
- * Initialize controller with logger and config (called by the framework)
86
+ * Clear the ambient init context after controller construction.
87
+ * Called by the framework (OneBunModule) after `new ControllerClass(...)`.
88
+ * @internal
89
+ */
90
+ static clearInitContext(): void {
91
+ Controller._initContext = null;
92
+ }
93
+
94
+ constructor() {
95
+ // Pick up logger and config from ambient init context if available.
96
+ // This makes this.config and this.logger available immediately after super()
97
+ // in subclass constructors.
98
+ if (Controller._initContext) {
99
+ const { logger, config } = Controller._initContext;
100
+ const className = this.constructor.name;
101
+ this.logger = logger.child({ className });
102
+ this.config = config;
103
+ this._initialized = true;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Initialize controller with logger and config (called by the framework).
109
+ * This is a fallback for controllers not constructed through the DI system
110
+ * (e.g., in tests or when created manually). If the controller was already
111
+ * initialized via the constructor init context, this is a no-op.
48
112
  * @internal
49
113
  */
50
114
  initializeController(logger: SyncLogger, config: IConfig<OneBunAppConfig>): void {
115
+ if (this._initialized) {
116
+ return; // Already initialized (via constructor or previous call)
117
+ }
118
+
51
119
  const className = this.constructor.name;
52
120
 
53
121
  if (logger) {
@@ -61,6 +129,7 @@ export class Controller {
61
129
 
62
130
  // Set configuration instance
63
131
  this.config = config;
132
+ this._initialized = true;
64
133
 
65
134
  this.logger.debug(`Controller ${className} initialized`);
66
135
  }
@@ -11,6 +11,11 @@ import type { SyncLogger } from '@onebun/logger';
11
11
  * Constructor-based DI is fully supported — inject any service from
12
12
  * the module's DI scope just like you would in a controller.
13
13
  *
14
+ * Middleware extending this class have `this.config` and `this.logger` available
15
+ * immediately after `super()` in the constructor when created through the framework DI.
16
+ * The framework sets an ambient init context before calling the constructor, and
17
+ * BaseMiddleware reads from it in its constructor.
18
+ *
14
19
  * Middleware is instantiated **once** at application startup and reused
15
20
  * for every matching request.
16
21
  *
@@ -24,17 +29,20 @@ import type { SyncLogger } from '@onebun/logger';
24
29
  * }
25
30
  * ```
26
31
  *
27
- * @example Middleware with DI
32
+ * @example Middleware with DI and constructor access
28
33
  * ```typescript
29
34
  * class AuthMiddleware extends BaseMiddleware {
35
+ * private readonly jwtSecret: string;
36
+ *
30
37
  * constructor(private authService: AuthService) {
31
38
  * super();
39
+ * // this.config and this.logger are available here!
40
+ * this.jwtSecret = this.config.get('auth.jwtSecret');
32
41
  * }
33
42
  *
34
43
  * async use(req: OneBunRequest, next: () => Promise<OneBunResponse>) {
35
- * const secret = this.config.get('auth.jwtSecret');
36
44
  * const token = req.headers.get('Authorization');
37
- * if (!this.authService.verify(token, secret)) {
45
+ * if (!this.authService.verify(token, this.jwtSecret)) {
38
46
  * this.logger.warn('Authentication failed');
39
47
  * return new Response('Unauthorized', { status: 401 });
40
48
  * }
@@ -50,15 +58,66 @@ export abstract class BaseMiddleware {
50
58
  /** Configuration instance for accessing environment variables */
51
59
  protected config!: IConfig<OneBunAppConfig>;
52
60
 
61
+ /** Flag to track initialization status */
62
+ private _initialized = false;
63
+
64
+ /**
65
+ * Ambient init context set by the framework before middleware construction.
66
+ * This allows the BaseMiddleware constructor to pick up logger and config
67
+ * so they are available immediately after super() in subclass constructors.
68
+ * @internal
69
+ */
70
+ private static _initContext: { logger: SyncLogger; config: IConfig<OneBunAppConfig> } | null =
71
+ null;
72
+
73
+ /**
74
+ * Set the ambient init context before constructing a middleware.
75
+ * Called by the framework (OneBunModule) before `new MiddlewareClass(...)`.
76
+ * @internal
77
+ */
78
+ static setInitContext(logger: SyncLogger, config: IConfig<OneBunAppConfig>): void {
79
+ BaseMiddleware._initContext = { logger, config };
80
+ }
81
+
82
+ /**
83
+ * Clear the ambient init context after middleware construction.
84
+ * Called by the framework (OneBunModule) after `new MiddlewareClass(...)`.
85
+ * @internal
86
+ */
87
+ static clearInitContext(): void {
88
+ BaseMiddleware._initContext = null;
89
+ }
90
+
91
+ constructor() {
92
+ // Pick up logger and config from ambient init context if available.
93
+ // This makes this.config and this.logger available immediately after super()
94
+ // in subclass constructors.
95
+ if (BaseMiddleware._initContext) {
96
+ const { logger, config } = BaseMiddleware._initContext;
97
+ const className = this.constructor.name;
98
+ this.logger = logger.child({ className });
99
+ this.config = config;
100
+ this._initialized = true;
101
+ }
102
+ }
103
+
53
104
  /**
54
105
  * Initialize middleware with logger and config.
106
+ * This is a fallback for middleware not constructed through the DI system
107
+ * (e.g., in tests or when created manually). If the middleware was already
108
+ * initialized via the constructor init context, this is a no-op.
55
109
  * Called by the framework after DI construction — do NOT call manually.
56
110
  * @internal
57
111
  */
58
112
  initializeMiddleware(logger: SyncLogger, config: IConfig<OneBunAppConfig>): void {
113
+ if (this._initialized) {
114
+ return; // Already initialized (via constructor or previous call)
115
+ }
116
+
59
117
  const className = this.constructor.name;
60
118
  this.logger = logger.child({ className });
61
119
  this.config = config;
120
+ this._initialized = true;
62
121
  this.logger.debug(`Middleware ${className} initialized`);
63
122
  }
64
123
 
@@ -26,6 +26,8 @@ import type {
26
26
 
27
27
  import { Controller as CtrlDeco, Module } from '../decorators/decorators';
28
28
  import { makeMockLoggerLayer } from '../testing/test-utils';
29
+ import { BaseWebSocketGateway } from '../websocket/ws-base-gateway';
30
+ import { WebSocketGateway } from '../websocket/ws-decorators';
29
31
 
30
32
  import { Controller as CtrlBase } from './controller';
31
33
  import { BaseMiddleware } from './middleware';
@@ -963,6 +965,230 @@ describe('OneBunModule', () => {
963
965
  });
964
966
  });
965
967
 
968
+ describe('Controller with ambient init context (config/logger in constructor)', () => {
969
+ const { Module: ModuleDecorator, Controller: ControllerDecorator, clearGlobalModules } = require('../decorators/decorators');
970
+ const { clearGlobalServicesRegistry: clearRegistry, OneBunModule: ModuleClass } = require('./module');
971
+ const { Controller: BaseController } = require('./controller');
972
+
973
+ beforeEach(() => {
974
+ clearGlobalModules();
975
+ clearRegistry();
976
+ });
977
+
978
+ afterEach(() => {
979
+ clearGlobalModules();
980
+ clearRegistry();
981
+ });
982
+
983
+ /**
984
+ * Test that this.config and this.logger are available in the controller constructor
985
+ * when the controller is created through the DI system (via ambient init context).
986
+ */
987
+ test('should have this.config and this.logger available in controller constructor via DI', async () => {
988
+ let configInConstructor: unknown = undefined;
989
+ let loggerInConstructor: unknown = undefined;
990
+
991
+ @ControllerDecorator('/test')
992
+ class TestCtrl extends BaseController {
993
+ constructor() {
994
+ super();
995
+ configInConstructor = this.config;
996
+ loggerInConstructor = this.logger;
997
+ }
998
+ }
999
+
1000
+ @ModuleDecorator({
1001
+ controllers: [TestCtrl],
1002
+ })
1003
+ class TestModule {}
1004
+
1005
+ // Initialize module and run setup — this triggers DI and controller creation
1006
+ const module = new ModuleClass(TestModule, mockLoggerLayer);
1007
+ module.getLayer();
1008
+ await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
1009
+
1010
+ // config and logger should have been available in the constructor
1011
+ expect(configInConstructor).toBeDefined();
1012
+ expect(loggerInConstructor).toBeDefined();
1013
+ });
1014
+
1015
+ /**
1016
+ * Test that this.config and this.logger are available in the controller constructor
1017
+ * when the controller has injected service dependencies.
1018
+ */
1019
+ test('should have this.config and this.logger in constructor of controller with dependencies', async () => {
1020
+ let configAvailable = false;
1021
+ let loggerAvailable = false;
1022
+
1023
+ @Service()
1024
+ class SomeService {
1025
+ getValue() {
1026
+ return 42;
1027
+ }
1028
+ }
1029
+
1030
+ @ControllerDecorator('/test')
1031
+ class TestCtrl extends BaseController {
1032
+ constructor(private svc: SomeService) {
1033
+ super();
1034
+ configAvailable = this.config !== undefined;
1035
+ loggerAvailable = this.logger !== undefined;
1036
+ }
1037
+ }
1038
+
1039
+ @ModuleDecorator({
1040
+ providers: [SomeService],
1041
+ controllers: [TestCtrl],
1042
+ })
1043
+ class TestModule {}
1044
+
1045
+ const module = new ModuleClass(TestModule, mockLoggerLayer);
1046
+ module.getLayer();
1047
+ await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
1048
+
1049
+ expect(configAvailable).toBe(true);
1050
+ expect(loggerAvailable).toBe(true);
1051
+ });
1052
+ });
1053
+
1054
+ describe('WebSocket gateway with ambient init context (config/logger in constructor)', () => {
1055
+ const { Module: ModuleDecorator, clearGlobalModules } = require('../decorators/decorators');
1056
+ const { clearGlobalServicesRegistry: clearRegistry, OneBunModule: ModuleClass } = require('./module');
1057
+
1058
+ beforeEach(() => {
1059
+ clearGlobalModules();
1060
+ clearRegistry();
1061
+ });
1062
+
1063
+ afterEach(() => {
1064
+ clearGlobalModules();
1065
+ clearRegistry();
1066
+ });
1067
+
1068
+ /**
1069
+ * Test that this.config and this.logger are available in the WS gateway constructor
1070
+ * when the gateway is created through the DI system (via ambient init context).
1071
+ */
1072
+ test('should have this.config and this.logger available in WS gateway constructor via DI', async () => {
1073
+ let configInConstructor: unknown = undefined;
1074
+ let loggerInConstructor: unknown = undefined;
1075
+
1076
+ @WebSocketGateway({ path: '/ws' })
1077
+ class TestGateway extends BaseWebSocketGateway {
1078
+ constructor() {
1079
+ super();
1080
+ configInConstructor = this.config;
1081
+ loggerInConstructor = this.logger;
1082
+ }
1083
+ }
1084
+
1085
+ @ModuleDecorator({
1086
+ controllers: [TestGateway],
1087
+ })
1088
+ class TestModule {}
1089
+
1090
+ // Initialize module and run setup — this triggers DI and gateway creation
1091
+ const module = new ModuleClass(TestModule, mockLoggerLayer);
1092
+ module.getLayer();
1093
+ await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
1094
+
1095
+ // config and logger should have been available in the constructor
1096
+ expect(configInConstructor).toBeDefined();
1097
+ expect(loggerInConstructor).toBeDefined();
1098
+ });
1099
+
1100
+ /**
1101
+ * Test that this.config and this.logger are available in the WS gateway constructor
1102
+ * when the gateway has injected service dependencies.
1103
+ */
1104
+ test('should have this.config and this.logger in constructor of WS gateway with dependencies', async () => {
1105
+ let configAvailable = false;
1106
+ let loggerAvailable = false;
1107
+
1108
+ @Service()
1109
+ class WsAuthService {
1110
+ verify() {
1111
+ return true;
1112
+ }
1113
+ }
1114
+
1115
+ @WebSocketGateway({ path: '/ws' })
1116
+ class TestGateway extends BaseWebSocketGateway {
1117
+ constructor(private auth: WsAuthService) {
1118
+ super();
1119
+ configAvailable = this.config !== undefined;
1120
+ loggerAvailable = this.logger !== undefined;
1121
+ }
1122
+ }
1123
+
1124
+ @ModuleDecorator({
1125
+ providers: [WsAuthService],
1126
+ controllers: [TestGateway],
1127
+ })
1128
+ class TestModule {}
1129
+
1130
+ const module = new ModuleClass(TestModule, mockLoggerLayer);
1131
+ module.getLayer();
1132
+ await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
1133
+
1134
+ expect(configAvailable).toBe(true);
1135
+ expect(loggerAvailable).toBe(true);
1136
+ });
1137
+ });
1138
+
1139
+ describe('Middleware with ambient init context (config/logger in constructor)', () => {
1140
+ const { Module: ModuleDecorator, Controller: ControllerDecorator, clearGlobalModules } = require('../decorators/decorators');
1141
+ const { clearGlobalServicesRegistry: clearRegistry, OneBunModule: ModuleInstance } = require('./module');
1142
+ const { Controller: BaseController } = require('./controller');
1143
+
1144
+ beforeEach(() => {
1145
+ clearGlobalModules();
1146
+ clearRegistry();
1147
+ });
1148
+
1149
+ afterEach(() => {
1150
+ clearGlobalModules();
1151
+ clearRegistry();
1152
+ });
1153
+
1154
+ /**
1155
+ * Test that this.config and this.logger are available in the middleware constructor
1156
+ * when the middleware is created through the DI system (via ambient init context).
1157
+ */
1158
+ test('should have this.config and this.logger available in middleware constructor via DI', () => {
1159
+ let configInConstructor: unknown = undefined;
1160
+ let loggerInConstructor: unknown = undefined;
1161
+
1162
+ class TestMiddleware extends BaseMiddleware {
1163
+ constructor() {
1164
+ super();
1165
+ configInConstructor = this.config;
1166
+ loggerInConstructor = this.logger;
1167
+ }
1168
+
1169
+ async use(req: OneBunRequest, next: () => Promise<OneBunResponse>): Promise<OneBunResponse> {
1170
+ return await next();
1171
+ }
1172
+ }
1173
+
1174
+ @ControllerDecorator('/test')
1175
+ class TestCtrl extends BaseController {}
1176
+
1177
+ @ModuleDecorator({
1178
+ controllers: [TestCtrl],
1179
+ })
1180
+ class TestModule {}
1181
+
1182
+ // Initialize module and resolve middleware (as the framework does)
1183
+ const module = new ModuleInstance(TestModule, mockLoggerLayer);
1184
+ module.resolveMiddleware([TestMiddleware]);
1185
+
1186
+ // config and logger should have been available in the constructor
1187
+ expect(configInConstructor).toBeDefined();
1188
+ expect(loggerInConstructor).toBeDefined();
1189
+ });
1190
+ });
1191
+
966
1192
  describe('Lifecycle hooks', () => {
967
1193
  const { clearGlobalModules } = require('../decorators/decorators');
968
1194
  const { clearGlobalServicesRegistry: clearRegistry, OneBunModule: ModuleClass } = require('./module');
@@ -4,9 +4,9 @@ import {
4
4
  Layer,
5
5
  } from 'effect';
6
6
 
7
- import type { Controller } from './controller';
8
7
  import type { ModuleInstance } from '../types';
9
8
 
9
+
10
10
  import {
11
11
  createSyncLogger,
12
12
  type Logger,
@@ -22,6 +22,7 @@ import {
22
22
  isGlobalModule,
23
23
  registerControllerDependencies,
24
24
  } from '../decorators/decorators';
25
+ import { BaseWebSocketGateway } from '../websocket/ws-base-gateway';
25
26
  import { isWebSocketGateway } from '../websocket/ws-decorators';
26
27
 
27
28
  import {
@@ -29,6 +30,7 @@ import {
29
30
  type IConfig,
30
31
  type OneBunAppConfig,
31
32
  } from './config.interface';
33
+ import { Controller } from './controller';
32
34
  import {
33
35
  hasOnModuleInit,
34
36
  hasOnApplicationInit,
@@ -461,7 +463,19 @@ export class OneBunModule implements ModuleInstance {
461
463
  }
462
464
 
463
465
  const middlewareConstructor = cls as new (...args: unknown[]) => BaseMiddleware;
464
- const instance = new middlewareConstructor(...deps);
466
+
467
+ // Set ambient init context so BaseMiddleware constructor can pick up logger/config,
468
+ // making them available immediately after super() in subclass constructors.
469
+ BaseMiddleware.setInitContext(this.logger, this.config);
470
+ let instance: BaseMiddleware;
471
+ try {
472
+ instance = new middlewareConstructor(...deps);
473
+ } finally {
474
+ BaseMiddleware.clearInitContext();
475
+ }
476
+
477
+ // Fallback: call initializeMiddleware for middleware not initialized via
478
+ // the constructor (e.g., not extending BaseMiddleware, or for backwards compatibility).
465
479
  instance.initializeMiddleware(this.logger, this.config);
466
480
 
467
481
  return instance.use.bind(instance);
@@ -533,15 +547,38 @@ export class OneBunModule implements ModuleInstance {
533
547
  }
534
548
  }
535
549
 
536
- // Logger and config are now injected separately via initializeController
537
- // No need to add them to constructor dependencies
538
-
539
- // Create controller with resolved dependencies
550
+ // Create controller with resolved dependencies.
551
+ // Set ambient init context so the base class constructor can pick up logger/config,
552
+ // making them available immediately after super() in subclass constructors.
540
553
  const controllerConstructor = controllerClass as new (...args: unknown[]) => Controller;
541
- const controller = new controllerConstructor(...dependencies);
554
+ const isGateway = isWebSocketGateway(controllerClass);
555
+ let controller: Controller;
542
556
 
543
- // Initialize controller with logger and config (skip for WebSocket gateways)
544
- if (!isWebSocketGateway(controllerClass) && typeof controller.initializeController === 'function') {
557
+ if (isGateway) {
558
+ BaseWebSocketGateway.setInitContext(this.logger, this.config);
559
+ } else {
560
+ Controller.setInitContext(this.logger, this.config);
561
+ }
562
+
563
+ try {
564
+ controller = new controllerConstructor(...dependencies);
565
+ } finally {
566
+ if (isGateway) {
567
+ BaseWebSocketGateway.clearInitContext();
568
+ } else {
569
+ Controller.clearInitContext();
570
+ }
571
+ }
572
+
573
+ // Fallback: call initializeController / _initializeBase for controllers/gateways
574
+ // that were not initialized via the constructor (e.g., not extending the base class,
575
+ // or for backwards compatibility).
576
+ if (isGateway) {
577
+ const gateway = controller as unknown as BaseWebSocketGateway;
578
+ if (typeof gateway._initializeBase === 'function') {
579
+ gateway._initializeBase(this.logger, this.config);
580
+ }
581
+ } else if (typeof controller.initializeController === 'function') {
545
582
  controller.initializeController(this.logger, this.config);
546
583
  }
547
584
 
@@ -1,3 +1,7 @@
1
+ /* eslint-disable
2
+ @typescript-eslint/no-explicit-any,
3
+ @typescript-eslint/naming-convention,
4
+ @typescript-eslint/no-empty-function */
1
5
  /**
2
6
  * Unit tests for ws-base-gateway.ts
3
7
  */
@@ -49,22 +53,22 @@ function createClientData(id: string, rooms: string[] = []): WsClientData {
49
53
  class TestGateway extends BaseWebSocketGateway {
50
54
  // Expose internal methods for testing
51
55
  public exposeRegisterSocket(clientId: string, socket: MockWebSocket): void {
52
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
+
53
57
  (this as any)._registerSocket(clientId, socket);
54
58
  }
55
59
 
56
60
  public exposeUnregisterSocket(clientId: string): void {
57
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
+
58
62
  (this as any)._unregisterSocket(clientId);
59
63
  }
60
64
 
61
65
  public exposeInitialize(storage: InMemoryWsStorage, server: unknown): void {
62
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
66
+
63
67
  (this as any)._initialize(storage, server);
64
68
  }
65
69
 
66
70
  public exposeGetSocket(clientId: string): MockWebSocket | undefined {
67
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+
68
72
  return (this as any).getSocket(clientId);
69
73
  }
70
74
  }
@@ -477,4 +481,146 @@ describe('BaseWebSocketGateway', () => {
477
481
  expect(typeof wsServer?.publish).toBe('function');
478
482
  });
479
483
  });
484
+
485
+ describe('Initialization via static init context (constructor)', () => {
486
+ let mockLogger: ReturnType<typeof createMockLogger>;
487
+ let mockConfig: ReturnType<typeof createMockConfig>;
488
+
489
+ function createMockLogger() {
490
+ const logger = {
491
+ debug: mock(() => {}),
492
+ info: mock(() => {}),
493
+ warn: mock(() => {}),
494
+ error: mock(() => {}),
495
+ fatal: mock(() => {}),
496
+ trace: mock(() => {}),
497
+ child: mock(() => logger),
498
+ };
499
+
500
+ return logger;
501
+ }
502
+
503
+ function createMockConfig(data: Record<string, unknown> = {}) {
504
+ return {
505
+ get: mock((key: string) => data[key]),
506
+ getOrThrow: mock((key: string) => {
507
+ if (key in data) {
508
+ return data[key];
509
+ }
510
+ throw new Error(`Missing config: ${key}`);
511
+ }),
512
+ has: mock((key: string) => key in data),
513
+ set: mock(() => {}),
514
+ entries: mock(() => Object.entries(data)),
515
+ } as any;
516
+ }
517
+
518
+ beforeEach(() => {
519
+ mockLogger = createMockLogger();
520
+ mockConfig = createMockConfig({
521
+ 'ws.port': 8080,
522
+ 'ws.path': '/ws',
523
+ });
524
+ });
525
+
526
+ it('should initialize gateway via static init context in constructor', () => {
527
+ class ContextTestGateway extends BaseWebSocketGateway {
528
+ configAvailableInConstructor = false;
529
+ loggerAvailableInConstructor = false;
530
+
531
+ constructor() {
532
+ super();
533
+ this.configAvailableInConstructor = this.config !== undefined;
534
+ this.loggerAvailableInConstructor = this.logger !== undefined;
535
+ }
536
+ }
537
+
538
+ BaseWebSocketGateway.setInitContext(mockLogger as any, mockConfig);
539
+ let gw: ContextTestGateway;
540
+ try {
541
+ gw = new ContextTestGateway();
542
+ } finally {
543
+ BaseWebSocketGateway.clearInitContext();
544
+ }
545
+
546
+ expect(gw.configAvailableInConstructor).toBe(true);
547
+ expect(gw.loggerAvailableInConstructor).toBe(true);
548
+ expect((gw as any).config).toBe(mockConfig);
549
+ });
550
+
551
+ it('should allow using config.get() in constructor when init context is set', () => {
552
+ class ContextTestGateway extends BaseWebSocketGateway {
553
+ readonly wsPort: number;
554
+
555
+ constructor() {
556
+ super();
557
+ this.wsPort = this.config.get('ws.port') as number;
558
+ }
559
+ }
560
+
561
+ BaseWebSocketGateway.setInitContext(mockLogger as any, mockConfig);
562
+ let gw: ContextTestGateway;
563
+ try {
564
+ gw = new ContextTestGateway();
565
+ } finally {
566
+ BaseWebSocketGateway.clearInitContext();
567
+ }
568
+
569
+ expect(gw.wsPort).toBe(8080);
570
+ });
571
+
572
+ it('should create child logger with correct className in constructor', () => {
573
+ class MyCustomGateway extends BaseWebSocketGateway {}
574
+
575
+ BaseWebSocketGateway.setInitContext(mockLogger as any, mockConfig);
576
+ try {
577
+ new MyCustomGateway();
578
+ } finally {
579
+ BaseWebSocketGateway.clearInitContext();
580
+ }
581
+
582
+ expect(mockLogger.child).toHaveBeenCalledWith({ className: 'MyCustomGateway' });
583
+ });
584
+
585
+ it('should not initialize if no init context is set', () => {
586
+ BaseWebSocketGateway.clearInitContext();
587
+
588
+ class ContextTestGateway extends BaseWebSocketGateway {}
589
+ const gw = new ContextTestGateway();
590
+
591
+ expect((gw as any)._initialized).toBe(false);
592
+ expect((gw as any).logger).toBeUndefined();
593
+ expect((gw as any).config).toBeUndefined();
594
+ });
595
+
596
+ it('_initializeBase should be a no-op if already initialized via init context', () => {
597
+ class ContextTestGateway extends BaseWebSocketGateway {}
598
+
599
+ BaseWebSocketGateway.setInitContext(mockLogger as any, mockConfig);
600
+ let gw: ContextTestGateway;
601
+ try {
602
+ gw = new ContextTestGateway();
603
+ } finally {
604
+ BaseWebSocketGateway.clearInitContext();
605
+ }
606
+
607
+ // Call _initializeBase again — should be a no-op
608
+ const otherLogger = createMockLogger();
609
+ const otherConfig = createMockConfig({ other: 'config' });
610
+ (gw as any)._initializeBase(otherLogger, otherConfig);
611
+
612
+ // Should still have original config (no reinit)
613
+ expect((gw as any).config).toBe(mockConfig);
614
+ });
615
+
616
+ it('clearInitContext should prevent subsequent constructors from picking up context', () => {
617
+ BaseWebSocketGateway.setInitContext(mockLogger as any, mockConfig);
618
+ BaseWebSocketGateway.clearInitContext();
619
+
620
+ class ContextTestGateway extends BaseWebSocketGateway {}
621
+ const gw = new ContextTestGateway();
622
+
623
+ expect((gw as any)._initialized).toBe(false);
624
+ });
625
+ });
480
626
  });
@@ -11,8 +11,11 @@ import type {
11
11
  WsRoom,
12
12
  WsServer,
13
13
  } from './ws.types';
14
+ import type { IConfig, OneBunAppConfig } from '../module/config.interface';
14
15
  import type { Server, ServerWebSocket } from 'bun';
15
16
 
17
+ import type { SyncLogger } from '@onebun/logger';
18
+
16
19
  import { createFullEventMessage, createNativeMessage } from './ws-socketio-protocol';
17
20
  import { WsStorageEvent, isPubSubAdapter } from './ws-storage';
18
21
 
@@ -31,12 +34,22 @@ export function _resetClientSocketsForTesting(): void {
31
34
  }
32
35
 
33
36
  /**
34
- * Base class for WebSocket gateways
37
+ * Base class for WebSocket gateways.
38
+ *
39
+ * Gateways extending this class have `this.config` and `this.logger` available
40
+ * immediately after `super()` in the constructor when created through the framework DI.
41
+ * The framework sets an ambient init context before calling the constructor, and
42
+ * BaseWebSocketGateway reads from it in its constructor.
35
43
  *
36
44
  * @example
37
45
  * ```typescript
38
46
  * @WebSocketGateway({ path: '/ws' })
39
47
  * export class ChatGateway extends BaseWebSocketGateway {
48
+ * constructor() {
49
+ * super();
50
+ * // this.config and this.logger are available here!
51
+ * }
52
+ *
40
53
  * @OnMessage('chat:message')
41
54
  * handleMessage(@Client() client: WsClientData, @MessageData() data: any) {
42
55
  * this.broadcast('chat:message', { userId: client.id, ...data });
@@ -45,6 +58,12 @@ export function _resetClientSocketsForTesting(): void {
45
58
  * ```
46
59
  */
47
60
  export abstract class BaseWebSocketGateway {
61
+ /** Logger instance with gateway class name as context */
62
+ protected logger!: SyncLogger;
63
+
64
+ /** Configuration instance for accessing environment variables */
65
+ protected config!: IConfig<OneBunAppConfig>;
66
+
48
67
  /** Storage adapter for persisting client/room data */
49
68
  protected storage: WsStorageAdapter | null = null;
50
69
 
@@ -54,10 +73,72 @@ export abstract class BaseWebSocketGateway {
54
73
  /** Unique instance ID (for multi-instance setups) */
55
74
  protected instanceId: string = crypto.randomUUID();
56
75
 
76
+ /** Flag to track initialization status */
77
+ private _initialized = false;
78
+
79
+ /**
80
+ * Ambient init context set by the framework before gateway construction.
81
+ * This allows the BaseWebSocketGateway constructor to pick up logger and config
82
+ * so they are available immediately after super() in subclass constructors.
83
+ * @internal
84
+ */
85
+ private static _initContext: { logger: SyncLogger; config: IConfig<OneBunAppConfig> } | null =
86
+ null;
87
+
88
+ /**
89
+ * Set the ambient init context before constructing a gateway.
90
+ * Called by the framework (OneBunModule) before `new GatewayClass(...)`.
91
+ * @internal
92
+ */
93
+ static setInitContext(logger: SyncLogger, config: IConfig<OneBunAppConfig>): void {
94
+ BaseWebSocketGateway._initContext = { logger, config };
95
+ }
96
+
97
+ /**
98
+ * Clear the ambient init context after gateway construction.
99
+ * Called by the framework (OneBunModule) after `new GatewayClass(...)`.
100
+ * @internal
101
+ */
102
+ static clearInitContext(): void {
103
+ BaseWebSocketGateway._initContext = null;
104
+ }
105
+
106
+ constructor() {
107
+ // Pick up logger and config from ambient init context if available.
108
+ // This makes this.config and this.logger available immediately after super()
109
+ // in subclass constructors.
110
+ if (BaseWebSocketGateway._initContext) {
111
+ const { logger, config } = BaseWebSocketGateway._initContext;
112
+ const className = this.constructor.name;
113
+ this.logger = logger.child({ className });
114
+ this.config = config;
115
+ this._initialized = true;
116
+ }
117
+ }
118
+
57
119
  // ============================================================================
58
120
  // Initialization
59
121
  // ============================================================================
60
122
 
123
+ /**
124
+ * Initialize the gateway with logger and config.
125
+ * This is a fallback for gateways not constructed through the DI system
126
+ * (e.g., in tests or when created manually). If the gateway was already
127
+ * initialized via the constructor init context, this is a no-op.
128
+ * Called internally by the framework during DI.
129
+ * @internal
130
+ */
131
+ _initializeBase(logger: SyncLogger, config: IConfig<OneBunAppConfig>): void {
132
+ if (this._initialized) {
133
+ return; // Already initialized (via constructor or previous call)
134
+ }
135
+
136
+ const className = this.constructor.name;
137
+ this.logger = logger.child({ className });
138
+ this.config = config;
139
+ this._initialized = true;
140
+ }
141
+
61
142
  /**
62
143
  * Initialize the gateway with storage and server
63
144
  * Called internally by the framework