@onebun/core 0.1.19 → 0.1.20

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.19",
3
+ "version": "0.1.20",
4
4
  "description": "Core package for OneBun framework - decorators, DI, modules, controllers",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -41,7 +41,7 @@
41
41
  "dependencies": {
42
42
  "effect": "^3.13.10",
43
43
  "arktype": "^2.0.0",
44
- "@onebun/logger": "^0.1.6",
44
+ "@onebun/logger": "^0.1.7",
45
45
  "@onebun/envs": "^0.1.4",
46
46
  "@onebun/metrics": "^0.1.6",
47
47
  "@onebun/requests": "^0.1.3",
@@ -2327,6 +2327,49 @@ describe('Architecture Documentation (docs/architecture.md)', () => {
2327
2327
  expect(SharedModule).toBeDefined();
2328
2328
  expect(ApiModule).toBeDefined();
2329
2329
  });
2330
+
2331
+ /**
2332
+ * Exports are only required for cross-module injection.
2333
+ * Within a module, any provider can be injected into controllers without being in exports.
2334
+ */
2335
+ it('should allow controller to inject same-module provider without exports', async () => {
2336
+ const effectLib = await import('effect');
2337
+ const moduleMod = await import('./module/module');
2338
+ const testUtils = await import('./testing/test-utils');
2339
+ const decorators = await import('./decorators/decorators');
2340
+
2341
+ @Service()
2342
+ class InternalService extends BaseService {
2343
+ getData(): string {
2344
+ return 'internal';
2345
+ }
2346
+ }
2347
+
2348
+ @Controller('/local')
2349
+ class LocalController extends BaseController {
2350
+ constructor(@decorators.Inject(InternalService) private readonly internal: InternalService) {
2351
+ super();
2352
+ }
2353
+ getData(): string {
2354
+ return this.internal.getData();
2355
+ }
2356
+ }
2357
+
2358
+ @Module({
2359
+ providers: [InternalService],
2360
+ controllers: [LocalController],
2361
+ // No exports - InternalService is only used inside this module
2362
+ })
2363
+ class LocalModule {}
2364
+
2365
+ const mod = new moduleMod.OneBunModule(LocalModule, testUtils.makeMockLoggerLayer());
2366
+ mod.getLayer();
2367
+ await effectLib.Effect.runPromise(mod.setup() as import('effect').Effect.Effect<unknown, never, never>);
2368
+
2369
+ const controller = mod.getControllerInstance(LocalController) as LocalController;
2370
+ expect(controller).toBeDefined();
2371
+ expect(controller.getData()).toBe('internal');
2372
+ });
2330
2373
  });
2331
2374
  });
2332
2375
 
@@ -10,7 +10,11 @@ import {
10
10
  afterEach,
11
11
  mock,
12
12
  } from 'bun:test';
13
- import { Context } from 'effect';
13
+ import {
14
+ Context,
15
+ Effect,
16
+ Layer,
17
+ } from 'effect';
14
18
 
15
19
  import { Module } from '../decorators/decorators';
16
20
  import { makeMockLoggerLayer } from '../testing/test-utils';
@@ -157,7 +161,6 @@ describe('OneBunModule', () => {
157
161
 
158
162
  test('should detect circular dependencies and provide detailed error message', () => {
159
163
  const { registerDependencies } = require('../decorators/decorators');
160
- const { Effect, Layer } = require('effect');
161
164
  const { LoggerService } = require('@onebun/logger');
162
165
 
163
166
  // Collect error messages
@@ -849,4 +852,145 @@ describe('OneBunModule', () => {
849
852
  expect((apiService as ApiService).getConnectionTimeout()).toBe(5000);
850
853
  });
851
854
  });
855
+
856
+ describe('Module DI scoping (exports only for cross-module)', () => {
857
+ const {
858
+ Controller: ControllerDecorator,
859
+ Get,
860
+ Inject,
861
+ clearGlobalModules,
862
+ } = require('../decorators/decorators');
863
+ const { Controller: BaseController } = require('./controller');
864
+ const { clearGlobalServicesRegistry: clearRegistry, OneBunModule: ModuleClass } = require('./module');
865
+
866
+ beforeEach(() => {
867
+ clearGlobalModules();
868
+ clearRegistry();
869
+ });
870
+
871
+ afterEach(() => {
872
+ clearGlobalModules();
873
+ clearRegistry();
874
+ });
875
+
876
+ test('controller can inject provider from same module without exports', async () => {
877
+ @Service()
878
+ class CounterService {
879
+ private count = 0;
880
+ getCount() {
881
+ return this.count;
882
+ }
883
+ increment() {
884
+ this.count += 1;
885
+ }
886
+ }
887
+
888
+ class CounterController extends BaseController {
889
+ constructor(@Inject(CounterService) private readonly counterService: CounterService) {
890
+ super();
891
+ }
892
+ getCount() {
893
+ return this.counterService.getCount();
894
+ }
895
+ }
896
+ const CounterControllerDecorated = ControllerDecorator('/counter')(CounterController);
897
+ Get('/')(CounterControllerDecorated.prototype, 'getCount', Object.getOwnPropertyDescriptor(CounterControllerDecorated.prototype, 'getCount')!);
898
+
899
+ @Module({
900
+ providers: [CounterService],
901
+ controllers: [CounterControllerDecorated],
902
+ // No exports - CounterService is only used inside this module
903
+ })
904
+ class FeatureModule {}
905
+
906
+ const module = new ModuleClass(FeatureModule, mockLoggerLayer);
907
+ module.getLayer();
908
+ await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
909
+
910
+ const controller = module.getControllerInstance(CounterControllerDecorated) as CounterController;
911
+ expect(controller).toBeDefined();
912
+ expect(controller.getCount()).toBe(0);
913
+ });
914
+
915
+ test('child module controller injects own provider; root can resolve controller', async () => {
916
+ @Service()
917
+ class ChildService {
918
+ getValue() {
919
+ return 'child';
920
+ }
921
+ }
922
+
923
+ class ChildController extends BaseController {
924
+ constructor(@Inject(ChildService) private readonly childService: ChildService) {
925
+ super();
926
+ }
927
+ getValue() {
928
+ return this.childService.getValue();
929
+ }
930
+ }
931
+ const ChildControllerDecorated = ControllerDecorator('/child')(ChildController);
932
+ Get('/')(ChildControllerDecorated.prototype, 'getValue', Object.getOwnPropertyDescriptor(ChildControllerDecorated.prototype, 'getValue')!);
933
+
934
+ @Module({
935
+ providers: [ChildService],
936
+ controllers: [ChildControllerDecorated],
937
+ })
938
+ class ChildModule {}
939
+
940
+ @Module({
941
+ imports: [ChildModule],
942
+ })
943
+ class RootModule {}
944
+
945
+ const rootModule = new ModuleClass(RootModule, mockLoggerLayer);
946
+ rootModule.getLayer();
947
+ await Effect.runPromise(rootModule.setup() as Effect.Effect<unknown, never, never>);
948
+
949
+ const allControllers = rootModule.getControllers();
950
+ expect(allControllers).toContain(ChildControllerDecorated);
951
+ const controller = rootModule.getControllerInstance(ChildControllerDecorated) as ChildController;
952
+ expect(controller).toBeDefined();
953
+ expect(controller.getValue()).toBe('child');
954
+ });
955
+
956
+ test('exported service from imported module is injectable in importing module', async () => {
957
+ @Service()
958
+ class SharedService {
959
+ getLabel() {
960
+ return 'shared';
961
+ }
962
+ }
963
+
964
+ @Module({
965
+ providers: [SharedService],
966
+ exports: [SharedService],
967
+ })
968
+ class SharedModule {}
969
+
970
+ class AppController extends BaseController {
971
+ constructor(@Inject(SharedService) private readonly sharedService: SharedService) {
972
+ super();
973
+ }
974
+ getLabel() {
975
+ return this.sharedService.getLabel();
976
+ }
977
+ }
978
+ const AppControllerDecorated = ControllerDecorator('/app')(AppController);
979
+ Get('/')(AppControllerDecorated.prototype, 'getLabel', Object.getOwnPropertyDescriptor(AppControllerDecorated.prototype, 'getLabel')!);
980
+
981
+ @Module({
982
+ imports: [SharedModule],
983
+ controllers: [AppControllerDecorated],
984
+ })
985
+ class AppModule {}
986
+
987
+ const module = new ModuleClass(AppModule, mockLoggerLayer);
988
+ module.getLayer();
989
+ await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
990
+
991
+ const controller = module.getControllerInstance(AppControllerDecorated) as AppController;
992
+ expect(controller).toBeDefined();
993
+ expect(controller.getLabel()).toBe('shared');
994
+ });
995
+ });
852
996
  });
@@ -169,9 +169,6 @@ export class OneBunModule implements ModuleInstance {
169
169
  // Merge layers
170
170
  layer = Layer.merge(layer, childModule.getLayer());
171
171
 
172
- // Add controllers from child module
173
- controllers.push(...childModule.getControllers());
174
-
175
172
  // Get exported services from child module and register them for DI
176
173
  const exportedServices = childModule.getExportedServices();
177
174
  for (const [tag, instance] of exportedServices) {
@@ -386,48 +383,20 @@ export class OneBunModule implements ModuleInstance {
386
383
  */
387
384
  createControllerInstances(): Effect.Effect<unknown, never, void> {
388
385
  return Effect.sync(() => {
389
- // Services are already created in initModule via createServicesWithDI
390
- // Just need to set up controllers with DI
391
-
392
- // Get module metadata to access providers for controller dependency registration
393
- const moduleMetadata = getModuleMetadata(this.moduleClass);
394
- if (moduleMetadata && moduleMetadata.providers) {
395
- // Create map of available services for dependency resolution
396
- const availableServices = new Map<string, Function>();
397
-
398
- // For each provider that is a class constructor, add to available services map
399
- for (const provider of moduleMetadata.providers) {
400
- if (typeof provider === 'function') {
401
- availableServices.set(provider.name, provider);
402
- }
403
- }
404
-
405
- // Also add services from imported modules
406
- for (const childModule of this.childModules) {
407
- const childMetadata = getModuleMetadata(childModule.moduleClass);
408
- if (childMetadata?.exports) {
409
- for (const exported of childMetadata.exports) {
410
- if (typeof exported === 'function') {
411
- availableServices.set(exported.name, exported);
412
- }
413
- }
414
- }
415
- }
416
-
417
- // Add global services to available services for dependency resolution
418
- for (const [, instance] of globalServicesRegistry) {
419
- if (instance && typeof instance === 'object') {
420
- availableServices.set(instance.constructor.name, instance.constructor);
421
- }
386
+ // Build map of available services from this module's DI scope (own providers + imported exports + global)
387
+ const availableServices = new Map<string, Function>();
388
+ for (const [, instance] of this.serviceInstances) {
389
+ if (instance && typeof instance === 'object') {
390
+ availableServices.set(instance.constructor.name, instance.constructor);
422
391
  }
392
+ }
423
393
 
424
- // Automatically analyze and register dependencies for all controllers
425
- for (const controllerClass of this.controllers) {
426
- registerControllerDependencies(controllerClass, availableServices);
427
- }
394
+ // Automatically analyze and register dependencies for all controllers of this module
395
+ for (const controllerClass of this.controllers) {
396
+ registerControllerDependencies(controllerClass, availableServices);
428
397
  }
429
398
 
430
- // Now create controller instances with automatic dependency injection
399
+ // Create controller instances with automatic dependency injection
431
400
  this.createControllersWithDI();
432
401
  }).pipe(Effect.provide(this.rootLayer));
433
402
  }
@@ -567,7 +536,12 @@ export class OneBunModule implements ModuleInstance {
567
536
  discard: true,
568
537
  }),
569
538
  ),
570
- // Then create controller instances
539
+ // Create controller instances in child modules first, then this module (each uses its own DI scope)
540
+ Effect.flatMap(() =>
541
+ Effect.forEach(this.childModules, (childModule) => childModule.createControllerInstances(), {
542
+ discard: true,
543
+ }),
544
+ ),
571
545
  Effect.flatMap(() => this.createControllerInstances()),
572
546
  // Then call onModuleInit for controllers
573
547
  Effect.flatMap(() => this.callControllersOnModuleInit()),
@@ -780,30 +754,46 @@ export class OneBunModule implements ModuleInstance {
780
754
  }
781
755
 
782
756
  /**
783
- * Get all controllers from this module
757
+ * Get all controllers from this module and child modules (recursive).
758
+ * Used by the application layer for routing and lifecycle.
784
759
  */
785
760
  getControllers(): Function[] {
786
- return this.controllers;
761
+ const fromChildren = this.childModules.flatMap((child) => child.getControllers());
762
+
763
+ return [...this.controllers, ...fromChildren];
787
764
  }
788
765
 
789
766
  /**
790
- * Get controller instance
767
+ * Get controller instance (searches this module then child modules recursively).
791
768
  */
792
769
  getControllerInstance(controllerClass: Function): Controller | undefined {
793
770
  const instance = this.controllerInstances.get(controllerClass);
794
-
795
- if (!instance) {
796
- this.logger.warn(`No instance found for controller ${controllerClass.name}`);
771
+ if (instance) {
772
+ return instance;
773
+ }
774
+ for (const childModule of this.childModules) {
775
+ const childInstance = childModule.getControllerInstance(controllerClass);
776
+ if (childInstance) {
777
+ return childInstance;
778
+ }
797
779
  }
780
+ this.logger.warn(`No instance found for controller ${controllerClass.name}`);
798
781
 
799
- return instance;
782
+ return undefined;
800
783
  }
801
784
 
802
785
  /**
803
- * Get all controller instances
786
+ * Get all controller instances from this module and child modules (recursive).
804
787
  */
805
788
  getControllerInstances(): Map<Function, Controller> {
806
- return this.controllerInstances;
789
+ const merged = new Map<Function, Controller>(this.controllerInstances);
790
+ for (const childModule of this.childModules) {
791
+ for (const [cls, instance] of childModule.getControllerInstances()) {
792
+ merged.set(cls, instance);
793
+ }
794
+ }
795
+
796
+ return merged;
807
797
  }
808
798
 
809
799
  /**