@onebun/core 0.1.22 → 0.1.24

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.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Core package for OneBun framework - decorators, DI, modules, controllers",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -145,7 +145,7 @@ describe('OneBunApplication', () => {
145
145
  expect(config).toBeDefined();
146
146
  });
147
147
 
148
- test('should create config service when envSchema provided', () => {
148
+ test('should create config service when envSchema provided (duplicate check)', () => {
149
149
  @Module({})
150
150
  class TestModule {}
151
151
 
@@ -159,12 +159,9 @@ describe('OneBunApplication', () => {
159
159
 
160
160
  const app = createTestApp(TestModule, { envSchema });
161
161
 
162
- // Just check that config service was created
162
+ // Config service is created eagerly in the constructor
163
163
  const config = app.getConfig();
164
164
  expect(config).toBeDefined();
165
-
166
- // The actual value access might need the config to be fully initialized
167
- // which happens during runtime, not during construction
168
165
  });
169
166
 
170
167
  test('should provide typed access to config values via getConfig()', () => {
@@ -261,17 +258,34 @@ describe('OneBunApplication', () => {
261
258
  });
262
259
 
263
260
  describe('Layer methods', () => {
264
- test('should return layer from root module', () => {
261
+ let originalServe: typeof Bun.serve;
262
+
263
+ beforeEach(() => {
264
+ originalServe = Bun.serve;
265
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
266
+ (Bun as any).serve = mock(() => ({
267
+ stop: mock(),
268
+ port: 3000,
269
+ }));
270
+ });
271
+
272
+ afterEach(() => {
273
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
274
+ (Bun as any).serve = originalServe;
275
+ });
276
+
277
+ test('should return layer from root module', async () => {
265
278
  @Module({})
266
279
  class TestModule {}
267
280
 
268
281
  const app = createTestApp(TestModule);
282
+ await app.start();
269
283
 
270
284
  const layer = app.getLayer();
271
285
  expect(layer).toBeDefined();
272
286
  });
273
287
 
274
- test('should return layer for complex module structure', () => {
288
+ test('should return layer for complex module structure', async () => {
275
289
  class TestController {}
276
290
  class TestService {}
277
291
 
@@ -282,6 +296,7 @@ describe('OneBunApplication', () => {
282
296
  class TestModule {}
283
297
 
284
298
  const app = createTestApp(TestModule);
299
+ await app.start();
285
300
 
286
301
  const layer = app.getLayer();
287
302
  expect(layer).toBeDefined();
@@ -394,14 +409,13 @@ describe('OneBunApplication', () => {
394
409
  });
395
410
 
396
411
  describe('Module class handling', () => {
397
- test('should throw error for plain class without decorator', () => {
412
+ test('should throw error for plain class without decorator', async () => {
398
413
  class PlainModule {}
399
414
 
400
- // This should throw error without @Module decorator
401
- expect(() => {
402
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
403
- createTestApp(PlainModule as any);
404
- }).toThrow('Module PlainModule does not have @Module decorator');
415
+ // Module creation now happens in start(), so the error is thrown there
416
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
417
+ const app = createTestApp(PlainModule as any);
418
+ await expect(app.start()).rejects.toThrow('Module PlainModule does not have @Module decorator');
405
419
  });
406
420
 
407
421
  test('should handle class with constructor parameters', () => {
@@ -152,12 +152,14 @@ function resolveHost(explicitHost: string | undefined): string {
152
152
  * OneBun Application
153
153
  */
154
154
  export class OneBunApplication {
155
- private rootModule: ModuleInstance;
155
+ private rootModule: ModuleInstance | null = null;
156
156
  private server: ReturnType<typeof Bun.serve> | null = null;
157
157
  private options: ApplicationOptions;
158
158
  private logger: SyncLogger;
159
159
  private config: IConfig<OneBunAppConfig>;
160
160
  private configService: ConfigServiceImpl | null = null;
161
+ private moduleClass: new (...args: unknown[]) => object;
162
+ private loggerLayer: Layer.Layer<never, never, unknown>;
161
163
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
162
164
  private metricsService: any = null;
163
165
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -176,6 +178,8 @@ export class OneBunApplication {
176
178
  moduleClass: new (...args: unknown[]) => object,
177
179
  options?: Partial<ApplicationOptions>,
178
180
  ) {
181
+ this.moduleClass = moduleClass;
182
+
179
183
  // Resolve port and host with priority: explicit > env > default
180
184
  this.options = {
181
185
  port: resolvePort(options?.port),
@@ -194,7 +198,7 @@ export class OneBunApplication {
194
198
 
195
199
  // Use provided logger layer, or create from options, or use default
196
200
  // Priority: loggerLayer > loggerOptions > env variables > NODE_ENV defaults
197
- const loggerLayer = this.options.loggerLayer
201
+ this.loggerLayer = this.options.loggerLayer
198
202
  ?? (this.options.loggerOptions
199
203
  ? makeLoggerFromOptions(this.options.loggerOptions)
200
204
  : makeLogger());
@@ -205,13 +209,14 @@ export class OneBunApplication {
205
209
  Effect.map(LoggerService, (logger: Logger) =>
206
210
  logger.child({ className: 'OneBunApplication' }),
207
211
  ),
208
- loggerLayer,
209
- ),
212
+ this.loggerLayer,
213
+ ) as Effect.Effect<Logger, never, never>,
210
214
  ) as Logger;
211
215
  this.logger = createSyncLogger(effectLogger);
212
216
 
213
- // Create configuration service if config is initialized
214
- if (this.config.isInitialized || !(this.config instanceof NotInitializedConfig)) {
217
+ // Create configuration service eagerly if config exists (it stores a reference,
218
+ // doesn't call config.get(), so safe before initialization)
219
+ if (!(this.config instanceof NotInitializedConfig)) {
215
220
  this.configService = new ConfigServiceImpl(this.logger, this.config);
216
221
  }
217
222
 
@@ -274,8 +279,8 @@ export class OneBunApplication {
274
279
  }
275
280
  }
276
281
 
277
- // Create the root module with logger layer and config
278
- this.rootModule = OneBunModule.create(moduleClass, loggerLayer, this.config);
282
+ // Note: root module creation is deferred to start() to ensure
283
+ // config is fully initialized before services are created.
279
284
  }
280
285
 
281
286
  /**
@@ -323,11 +328,23 @@ export class OneBunApplication {
323
328
  return this.getConfig().get(path);
324
329
  }
325
330
 
331
+ /**
332
+ * Ensure root module is created (i.e., start() has been called).
333
+ * Throws if called before start().
334
+ */
335
+ private ensureModule(): ModuleInstance {
336
+ if (!this.rootModule) {
337
+ throw new Error('Application not started. Call start() before accessing the module.');
338
+ }
339
+
340
+ return this.rootModule;
341
+ }
342
+
326
343
  /**
327
344
  * Get root module layer
328
345
  */
329
346
  getLayer(): Layer.Layer<never, never, unknown> {
330
- return this.rootModule.getLayer();
347
+ return this.ensureModule().getLayer();
331
348
  }
332
349
 
333
350
  /**
@@ -377,6 +394,10 @@ export class OneBunApplication {
377
394
  this.logger.info('Application configuration initialized');
378
395
  }
379
396
 
397
+ // Create the root module AFTER config is initialized,
398
+ // so services can safely use this.config.get() in their constructors
399
+ this.rootModule = OneBunModule.create(this.moduleClass, this.loggerLayer, this.config);
400
+
380
401
  // Start metrics collection if enabled
381
402
  if (this.metricsService && this.metricsService.startSystemMetricsCollection) {
382
403
  this.metricsService.startSystemMetricsCollection();
@@ -384,10 +405,10 @@ export class OneBunApplication {
384
405
  }
385
406
 
386
407
  // Setup the module and create controller instances
387
- await Effect.runPromise(this.rootModule.setup() as Effect.Effect<unknown, never, never>);
408
+ await Effect.runPromise(this.ensureModule().setup() as Effect.Effect<unknown, never, never>);
388
409
 
389
410
  // Get all controllers from the root module
390
- const controllers = this.rootModule.getControllers();
411
+ const controllers = this.ensureModule().getControllers();
391
412
  this.logger.debug(`Loaded ${controllers.length} controllers`);
392
413
 
393
414
  // Initialize WebSocket handler and detect gateways
@@ -396,7 +417,7 @@ export class OneBunApplication {
396
417
  // Register WebSocket gateways (they are in controllers array but decorated with @WebSocketGateway)
397
418
  for (const controllerClass of controllers) {
398
419
  if (isWebSocketGateway(controllerClass)) {
399
- const instance = this.rootModule.getControllerInstance?.(controllerClass);
420
+ const instance = this.ensureModule().getControllerInstance?.(controllerClass);
400
421
  if (instance) {
401
422
  this.wsHandler.registerGateway(controllerClass, instance as import('../websocket/ws-base-gateway').BaseWebSocketGateway);
402
423
  this.logger.info(`Registered WebSocket gateway: ${controllerClass.name}`);
@@ -438,14 +459,14 @@ export class OneBunApplication {
438
459
  }
439
460
 
440
461
  // Get controller instance from module
441
- if (!this.rootModule.getControllerInstance) {
462
+ if (!this.ensureModule().getControllerInstance) {
442
463
  this.logger.warn(
443
464
  `Module does not support getControllerInstance for ${controllerClass.name}`,
444
465
  );
445
466
  continue;
446
467
  }
447
468
 
448
- const controller = this.rootModule.getControllerInstance(controllerClass) as Controller;
469
+ const controller = this.ensureModule().getControllerInstance!(controllerClass) as Controller;
449
470
  if (!controller) {
450
471
  this.logger.warn(`Controller instance not found for ${controllerClass.name}`);
451
472
  continue;
@@ -508,8 +529,8 @@ export class OneBunApplication {
508
529
  }
509
530
 
510
531
  // Call onApplicationInit lifecycle hook for all services and controllers
511
- if (this.rootModule.callOnApplicationInit) {
512
- await this.rootModule.callOnApplicationInit();
532
+ if (this.ensureModule().callOnApplicationInit) {
533
+ await this.ensureModule().callOnApplicationInit!();
513
534
  this.logger.debug('Application initialization hooks completed');
514
535
  }
515
536
 
@@ -1242,7 +1263,7 @@ export class OneBunApplication {
1242
1263
  this.logger.info('Stopping OneBun application...');
1243
1264
 
1244
1265
  // Call beforeApplicationDestroy lifecycle hook
1245
- if (this.rootModule.callBeforeApplicationDestroy) {
1266
+ if (this.rootModule?.callBeforeApplicationDestroy) {
1246
1267
  this.logger.debug('Calling beforeApplicationDestroy hooks');
1247
1268
  await this.rootModule.callBeforeApplicationDestroy(signal);
1248
1269
  }
@@ -1276,7 +1297,7 @@ export class OneBunApplication {
1276
1297
  }
1277
1298
 
1278
1299
  // Call onModuleDestroy lifecycle hook
1279
- if (this.rootModule.callOnModuleDestroy) {
1300
+ if (this.rootModule?.callOnModuleDestroy) {
1280
1301
  this.logger.debug('Calling onModuleDestroy hooks');
1281
1302
  await this.rootModule.callOnModuleDestroy();
1282
1303
  }
@@ -1288,7 +1309,7 @@ export class OneBunApplication {
1288
1309
  }
1289
1310
 
1290
1311
  // Call onApplicationDestroy lifecycle hook
1291
- if (this.rootModule.callOnApplicationDestroy) {
1312
+ if (this.rootModule?.callOnApplicationDestroy) {
1292
1313
  this.logger.debug('Calling onApplicationDestroy hooks');
1293
1314
  await this.rootModule.callOnApplicationDestroy(signal);
1294
1315
  }
@@ -1304,7 +1325,7 @@ export class OneBunApplication {
1304
1325
 
1305
1326
  // Check if any controller has queue-related decorators
1306
1327
  const hasQueueHandlers = controllers.some(controller => {
1307
- const instance = this.rootModule.getControllerInstance?.(controller);
1328
+ const instance = this.ensureModule().getControllerInstance?.(controller);
1308
1329
  if (!instance) {
1309
1330
  return false;
1310
1331
  }
@@ -1364,7 +1385,7 @@ export class OneBunApplication {
1364
1385
 
1365
1386
  // Register handlers from controllers using registerService
1366
1387
  for (const controllerClass of controllers) {
1367
- const instance = this.rootModule.getControllerInstance?.(controllerClass);
1388
+ const instance = this.ensureModule().getControllerInstance?.(controllerClass);
1368
1389
  if (!instance) {
1369
1390
  continue;
1370
1391
  }
@@ -1528,11 +1549,11 @@ export class OneBunApplication {
1528
1549
  * ```
1529
1550
  */
1530
1551
  getService<T>(serviceClass: new (...args: unknown[]) => T): T {
1531
- if (!this.rootModule.getServiceByClass) {
1552
+ if (!this.ensureModule().getServiceByClass) {
1532
1553
  throw new Error('Module does not support getServiceByClass');
1533
1554
  }
1534
1555
 
1535
- const service = this.rootModule.getServiceByClass(serviceClass);
1556
+ const service = this.ensureModule().getServiceByClass!(serviceClass);
1536
1557
  if (!service) {
1537
1558
  throw new Error(
1538
1559
  `Service ${serviceClass.name} not found. Make sure it's registered in the module's providers.`,
@@ -11,6 +11,7 @@ import {
11
11
  expect,
12
12
  beforeEach,
13
13
  afterEach,
14
+ mock,
14
15
  } from 'bun:test';
15
16
 
16
17
  import { OneBunApplication } from '../application';
@@ -817,50 +818,63 @@ describe('decorators', () => {
817
818
  });
818
819
 
819
820
  test('should properly inject module service into controller without @Inject', async () => {
820
- @Service()
821
- class TestService extends BaseService {
822
- getValue() {
823
- return 'injected-value';
821
+ // Mock Bun.serve to avoid starting a real server
822
+ const originalServe = Bun.serve;
823
+
824
+ (Bun as any).serve = mock(() => ({
825
+ stop: mock(),
826
+ port: 3000,
827
+ }));
828
+
829
+ try {
830
+ @Service()
831
+ class TestService extends BaseService {
832
+ getValue() {
833
+ return 'injected-value';
834
+ }
824
835
  }
825
- }
826
836
 
827
- // No @Inject needed - automatic DI via emitDecoratorMetadata
828
- @Controller('')
829
- class TestController extends BaseController {
830
- constructor(private service: TestService) {
831
- super();
832
- }
837
+ // No @Inject needed - automatic DI via emitDecoratorMetadata
838
+ @Controller('')
839
+ class TestController extends BaseController {
840
+ constructor(private service: TestService) {
841
+ super();
842
+ }
833
843
 
834
- getServiceValue() {
835
- return this.service.getValue();
844
+ getServiceValue() {
845
+ return this.service.getValue();
846
+ }
836
847
  }
837
- }
838
848
 
839
- @Module({
840
- controllers: [TestController],
841
- providers: [TestService],
842
- })
843
- class TestModule {}
849
+ @Module({
850
+ controllers: [TestController],
851
+ providers: [TestService],
852
+ })
853
+ class TestModule {}
844
854
 
845
- const app = new OneBunApplication(TestModule, {
846
- loggerLayer: makeMockLoggerLayer(),
847
- });
855
+ const app = new OneBunApplication(TestModule, {
856
+ loggerLayer: makeMockLoggerLayer(),
857
+ });
848
858
 
849
- // Access rootModule to setup and verify DI
850
- const rootModule = (app as any).rootModule;
859
+ // start() creates rootModule and calls setup, triggering dependency injection
860
+ await app.start();
851
861
 
852
- // Setup module to trigger dependency injection
853
- const { Effect } = await import('effect');
854
- await Effect.runPromise(rootModule.setup());
862
+ // Access rootModule after start to verify DI
863
+
864
+ const rootModule = (app as any).rootModule;
855
865
 
856
- // Get controller instance and verify service was injected
857
- const controllerInstance = rootModule.getControllerInstance(TestController) as TestController;
866
+ // Get controller instance and verify service was injected
867
+ const controllerInstance = rootModule.getControllerInstance(TestController) as TestController;
858
868
 
859
- expect(controllerInstance).toBeDefined();
860
- expect(controllerInstance).toBeInstanceOf(TestController);
869
+ expect(controllerInstance).toBeDefined();
870
+ expect(controllerInstance).toBeInstanceOf(TestController);
861
871
 
862
- // Verify the injected service works correctly
863
- expect(controllerInstance.getServiceValue()).toBe('injected-value');
872
+ // Verify the injected service works correctly
873
+ expect(controllerInstance.getServiceValue()).toBe('injected-value');
874
+ } finally {
875
+
876
+ (Bun as any).serve = originalServe;
877
+ }
864
878
  });
865
879
  });
866
880
 
@@ -816,7 +816,7 @@ describe('Services API Documentation Examples', () => {
816
816
  @Service()
817
817
  class UserService extends BaseService {
818
818
  // Dependencies are auto-injected via constructor
819
- // Logger and config are auto-injected via initializeService()
819
+ // Logger and config are available immediately after super()
820
820
  constructor(private repository: UserRepository) {
821
821
  super();
822
822
  }
@@ -851,6 +851,106 @@ describe('OneBunModule', () => {
851
851
  expect(apiService).toBeDefined();
852
852
  expect((apiService as ApiService).getConnectionTimeout()).toBe(5000);
853
853
  });
854
+
855
+ /**
856
+ * Test that this.config and this.logger are available in the service constructor
857
+ * when the service is created through the DI system (via ambient init context).
858
+ */
859
+ test('should have this.config and this.logger available in constructor via DI', () => {
860
+ let configInConstructor: unknown = undefined;
861
+ let loggerInConstructor: unknown = undefined;
862
+
863
+ @Service()
864
+ class ConfigAwareService extends BaseServiceClass {
865
+ constructor() {
866
+ super();
867
+ configInConstructor = this.config;
868
+ loggerInConstructor = this.logger;
869
+ }
870
+ }
871
+
872
+ @ModuleDecorator({
873
+ providers: [ConfigAwareService],
874
+ })
875
+ class TestModule {}
876
+
877
+ // Initialize module — this triggers DI and service creation
878
+ new ModuleInstance(TestModule, mockLoggerLayer);
879
+
880
+ // config and logger should have been available in the constructor
881
+ expect(configInConstructor).toBeDefined();
882
+ expect(loggerInConstructor).toBeDefined();
883
+ });
884
+
885
+ /**
886
+ * Test that this.config.get() works in the constructor for services created via DI
887
+ */
888
+ test('should allow config.get() in service constructor via DI', () => {
889
+ @Service()
890
+ class ServiceWithConfigInConstructor extends BaseServiceClass {
891
+ readonly configValue: unknown;
892
+
893
+ constructor() {
894
+ super();
895
+ // Config should be available here via init context
896
+ this.configValue = this.config;
897
+ }
898
+
899
+ getConfigValue() {
900
+ return this.configValue;
901
+ }
902
+ }
903
+
904
+ @ModuleDecorator({
905
+ providers: [ServiceWithConfigInConstructor],
906
+ })
907
+ class TestModule {}
908
+
909
+ const module = new ModuleInstance(TestModule, mockLoggerLayer);
910
+
911
+ const { getServiceTag } = require('./service');
912
+ const tag = getServiceTag(ServiceWithConfigInConstructor);
913
+ const service = module.getServiceInstance(tag) as ServiceWithConfigInConstructor;
914
+
915
+ expect(service).toBeDefined();
916
+ // configValue was captured in constructor — should not be undefined
917
+ expect(service.getConfigValue()).toBeDefined();
918
+ });
919
+
920
+ /**
921
+ * Test that this.config is available in constructor of a service with dependencies
922
+ */
923
+ test('should have this.config in constructor of service with dependencies', () => {
924
+ let configAvailable = false;
925
+
926
+ @Service()
927
+ class DependencyService {
928
+ getValue() {
929
+ return 42;
930
+ }
931
+ }
932
+
933
+ @Service()
934
+ class MainService extends BaseServiceClass {
935
+ constructor(private dep: DependencyService) {
936
+ super();
937
+ configAvailable = this.config !== undefined;
938
+ }
939
+
940
+ getDep() {
941
+ return this.dep.getValue();
942
+ }
943
+ }
944
+
945
+ @ModuleDecorator({
946
+ providers: [DependencyService, MainService],
947
+ })
948
+ class TestModule {}
949
+
950
+ new ModuleInstance(TestModule, mockLoggerLayer);
951
+
952
+ expect(configAvailable).toBe(true);
953
+ });
854
954
  });
855
955
 
856
956
  describe('Lifecycle hooks', () => {
@@ -36,7 +36,11 @@ import {
36
36
  hasBeforeApplicationDestroy,
37
37
  hasOnApplicationDestroy,
38
38
  } from './lifecycle';
39
- import { getServiceMetadata, getServiceTag } from './service';
39
+ import {
40
+ BaseService,
41
+ getServiceMetadata,
42
+ getServiceTag,
43
+ } from './service';
40
44
 
41
45
  /**
42
46
  * Global services registry
@@ -290,13 +294,23 @@ export class OneBunModule implements ModuleInstance {
290
294
  continue;
291
295
  }
292
296
 
293
- // Create service instance with resolved dependencies (without logger/config in constructor)
297
+ // Create service instance with resolved dependencies.
298
+ // Set ambient init context so BaseService constructor can pick up logger/config,
299
+ // making them available immediately after super() in subclass constructors.
294
300
  try {
295
301
  const serviceConstructor = provider as new (...args: unknown[]) => unknown;
296
- const serviceInstance = new serviceConstructor(...dependencies);
297
302
 
298
- // Initialize service with logger and config if it has initializeService method
299
- // This is the pattern used by BaseService
303
+ BaseService.setInitContext(this.logger, this.config);
304
+ let serviceInstance: unknown;
305
+ try {
306
+ serviceInstance = new serviceConstructor(...dependencies);
307
+ } finally {
308
+ BaseService.clearInitContext();
309
+ }
310
+
311
+ // Fallback: call initializeService for services that have it but were not
312
+ // initialized via the constructor (e.g., services not extending BaseService
313
+ // but implementing initializeService manually, or for backwards compatibility).
300
314
  if (
301
315
  serviceInstance &&
302
316
  typeof serviceInstance === 'object' &&
@@ -165,6 +165,111 @@ describe('BaseService', () => {
165
165
  });
166
166
  });
167
167
 
168
+ describe('Initialization via static init context (constructor)', () => {
169
+ test('should initialize service via static init context in constructor', () => {
170
+ class TestService extends BaseService {
171
+ configAvailableInConstructor = false;
172
+ loggerAvailableInConstructor = false;
173
+
174
+ constructor() {
175
+ super();
176
+ this.configAvailableInConstructor = this.config !== undefined;
177
+ this.loggerAvailableInConstructor = this.logger !== undefined;
178
+ }
179
+ }
180
+
181
+ // Set init context before construction (as the framework does)
182
+ BaseService.setInitContext(mockLogger, mockConfig);
183
+ let service: TestService;
184
+ try {
185
+ service = new TestService();
186
+ } finally {
187
+ BaseService.clearInitContext();
188
+ }
189
+
190
+ expect(service.isInitialized).toBe(true);
191
+ expect(service.configAvailableInConstructor).toBe(true);
192
+ expect(service.loggerAvailableInConstructor).toBe(true);
193
+ expect((service as any).config).toBe(mockConfig);
194
+ });
195
+
196
+ test('should allow using config.get() in constructor when init context is set', () => {
197
+ class TestService extends BaseService {
198
+ readonly dbHost: string;
199
+
200
+ constructor() {
201
+ super();
202
+ this.dbHost = this.config.get('database.host') as string;
203
+ }
204
+ }
205
+
206
+ BaseService.setInitContext(mockLogger, mockConfig);
207
+ let service: TestService;
208
+ try {
209
+ service = new TestService();
210
+ } finally {
211
+ BaseService.clearInitContext();
212
+ }
213
+
214
+ expect(service.dbHost).toBe('localhost');
215
+ });
216
+
217
+ test('should create child logger with correct className in constructor', () => {
218
+ class MyCustomService extends BaseService {}
219
+
220
+ BaseService.setInitContext(mockLogger, mockConfig);
221
+ try {
222
+ new MyCustomService();
223
+ } finally {
224
+ BaseService.clearInitContext();
225
+ }
226
+
227
+ expect(mockLogger.child).toHaveBeenCalledWith({ className: 'MyCustomService' });
228
+ });
229
+
230
+ test('should not initialize if no init context is set', () => {
231
+ // Ensure context is clear
232
+ BaseService.clearInitContext();
233
+
234
+ class TestService extends BaseService {}
235
+ const service = new TestService();
236
+
237
+ expect(service.isInitialized).toBe(false);
238
+ });
239
+
240
+ test('initializeService should be a no-op if already initialized via init context', () => {
241
+ class TestService extends BaseService {}
242
+
243
+ BaseService.setInitContext(mockLogger, mockConfig);
244
+ let service: TestService;
245
+ try {
246
+ service = new TestService();
247
+ } finally {
248
+ BaseService.clearInitContext();
249
+ }
250
+
251
+ expect(service.isInitialized).toBe(true);
252
+
253
+ // Call initializeService again — should be a no-op
254
+ const otherLogger = { ...mockLogger, child: mock(() => ({ ...mockLogger })) };
255
+ const otherConfig = createMockConfig({ other: 'config' });
256
+ service.initializeService(otherLogger, otherConfig);
257
+
258
+ // Should still have original config (no reinit)
259
+ expect((service as any).config).toBe(mockConfig);
260
+ });
261
+
262
+ test('clearInitContext should prevent subsequent constructors from picking up context', () => {
263
+ BaseService.setInitContext(mockLogger, mockConfig);
264
+ BaseService.clearInitContext();
265
+
266
+ class TestService extends BaseService {}
267
+ const service = new TestService();
268
+
269
+ expect(service.isInitialized).toBe(false);
270
+ });
271
+ });
272
+
168
273
  describe('Error handling edge cases', () => {
169
274
  test('should handle complex effect scenarios', async () => {
170
275
  class TestService extends BaseService {
@@ -19,8 +19,9 @@ const META_SERVICES = new Map<
19
19
  /**
20
20
  * Service decorator
21
21
  * Registers the class as a service with an optional Effect Context tag.
22
- * Services extending BaseService will have logger and config injected
23
- * via the initializeService method called by the module after instantiation.
22
+ * Services extending BaseService will have logger and config available
23
+ * immediately after super() in the constructor (via ambient init context),
24
+ * as well as through the initializeService fallback method.
24
25
  *
25
26
  * @param tag - Optional Effect Context tag for the service
26
27
  */
@@ -62,7 +63,26 @@ export function getServiceTag<T>(serviceClass: new (...args: unknown[]) => T): C
62
63
  }
63
64
 
64
65
  /**
65
- * Base service class that provides utility methods for working with Effect
66
+ * Base service class that provides utility methods for working with Effect.
67
+ *
68
+ * Services extending BaseService have `this.config` and `this.logger` available
69
+ * immediately after `super()` in the constructor when created through the framework DI.
70
+ * The framework sets an ambient init context before calling the constructor, and
71
+ * BaseService reads from it in `super()`.
72
+ *
73
+ * @example
74
+ * ```typescript
75
+ * @Service()
76
+ * class MyService extends BaseService {
77
+ * private readonly baseUrl: string;
78
+ *
79
+ * constructor(private dep: SomeDep) {
80
+ * super();
81
+ * // this.config and this.logger are available here!
82
+ * this.baseUrl = this.config.get('api.baseUrl');
83
+ * }
84
+ * }
85
+ * ```
66
86
  */
67
87
  export class BaseService {
68
88
  // Logger instance with service class name as context
@@ -73,12 +93,55 @@ export class BaseService {
73
93
  private _initialized = false;
74
94
 
75
95
  /**
76
- * Initialize service with logger and config (called by the framework)
96
+ * Ambient init context set by the framework before service construction.
97
+ * This allows BaseService constructor to pick up logger and config
98
+ * so they are available immediately after super() in subclass constructors.
99
+ * @internal
100
+ */
101
+ private static _initContext: { logger: SyncLogger; config: IConfig<OneBunAppConfig> } | null =
102
+ null;
103
+
104
+ /**
105
+ * Set the ambient init context before constructing a service.
106
+ * Called by the framework (OneBunModule) before `new ServiceClass(...)`.
107
+ * @internal
108
+ */
109
+ static setInitContext(logger: SyncLogger, config: IConfig<OneBunAppConfig>): void {
110
+ BaseService._initContext = { logger, config };
111
+ }
112
+
113
+ /**
114
+ * Clear the ambient init context after service construction.
115
+ * Called by the framework (OneBunModule) after `new ServiceClass(...)`.
116
+ * @internal
117
+ */
118
+ static clearInitContext(): void {
119
+ BaseService._initContext = null;
120
+ }
121
+
122
+ constructor() {
123
+ // Pick up logger and config from ambient init context if available.
124
+ // This makes this.config and this.logger available immediately after super()
125
+ // in subclass constructors.
126
+ if (BaseService._initContext) {
127
+ const { logger, config } = BaseService._initContext;
128
+ const className = this.constructor.name;
129
+ this.logger = logger.child({ className });
130
+ this.config = config;
131
+ this._initialized = true;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Initialize service with logger and config (called by the framework).
137
+ * This is a fallback for services not constructed through the DI system
138
+ * (e.g., in tests or when created manually). If the service was already
139
+ * initialized via the constructor init context, this is a no-op.
77
140
  * @internal
78
141
  */
79
142
  initializeService(logger: SyncLogger, config: IConfig<OneBunAppConfig>): void {
80
143
  if (this._initialized) {
81
- return; // Already initialized
144
+ return; // Already initialized (via constructor or previous call)
82
145
  }
83
146
 
84
147
  const className = this.constructor.name;