@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 +1 -1
- package/src/application/application.test.ts +27 -13
- package/src/application/application.ts +44 -23
- package/src/decorators/decorators.test.ts +47 -33
- package/src/docs-examples.test.ts +1 -1
- package/src/module/module.test.ts +100 -0
- package/src/module/module.ts +19 -5
- package/src/module/service.test.ts +105 -0
- package/src/module/service.ts +68 -5
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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
|
|
214
|
-
|
|
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
|
-
//
|
|
278
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
512
|
-
await this.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
1552
|
+
if (!this.ensureModule().getServiceByClass) {
|
|
1532
1553
|
throw new Error('Module does not support getServiceByClass');
|
|
1533
1554
|
}
|
|
1534
1555
|
|
|
1535
|
-
const service = this.
|
|
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
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
835
|
-
|
|
844
|
+
getServiceValue() {
|
|
845
|
+
return this.service.getValue();
|
|
846
|
+
}
|
|
836
847
|
}
|
|
837
|
-
}
|
|
838
848
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
849
|
+
@Module({
|
|
850
|
+
controllers: [TestController],
|
|
851
|
+
providers: [TestService],
|
|
852
|
+
})
|
|
853
|
+
class TestModule {}
|
|
844
854
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
855
|
+
const app = new OneBunApplication(TestModule, {
|
|
856
|
+
loggerLayer: makeMockLoggerLayer(),
|
|
857
|
+
});
|
|
848
858
|
|
|
849
|
-
|
|
850
|
-
|
|
859
|
+
// start() creates rootModule and calls setup, triggering dependency injection
|
|
860
|
+
await app.start();
|
|
851
861
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
862
|
+
// Access rootModule after start to verify DI
|
|
863
|
+
|
|
864
|
+
const rootModule = (app as any).rootModule;
|
|
855
865
|
|
|
856
|
-
|
|
857
|
-
|
|
866
|
+
// Get controller instance and verify service was injected
|
|
867
|
+
const controllerInstance = rootModule.getControllerInstance(TestController) as TestController;
|
|
858
868
|
|
|
859
|
-
|
|
860
|
-
|
|
869
|
+
expect(controllerInstance).toBeDefined();
|
|
870
|
+
expect(controllerInstance).toBeInstanceOf(TestController);
|
|
861
871
|
|
|
862
|
-
|
|
863
|
-
|
|
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
|
|
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', () => {
|
package/src/module/module.ts
CHANGED
|
@@ -36,7 +36,11 @@ import {
|
|
|
36
36
|
hasBeforeApplicationDestroy,
|
|
37
37
|
hasOnApplicationDestroy,
|
|
38
38
|
} from './lifecycle';
|
|
39
|
-
import {
|
|
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
|
|
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
|
-
|
|
299
|
-
|
|
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 {
|
package/src/module/service.ts
CHANGED
|
@@ -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
|
|
23
|
-
*
|
|
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
|
-
*
|
|
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;
|