diff --git a/src/container/Container.ts b/src/container/Container.ts index 1701f46..a69f6c5 100644 --- a/src/container/Container.ts +++ b/src/container/Container.ts @@ -2,8 +2,10 @@ import "reflect-metadata"; import { CircularDependencyError, ProviderNotFoundError } from "../errors"; import { ContextualModuleRef, ModuleRef } from "../module/ModuleRef"; import type { Constructor } from "../types"; +import type { ForwardRefFn } from "./forwardRef"; import { type BaseProvider, + type DynamicModule, INJECT_METADATA_KEY, INJECTABLE_METADATA_KEY, MODULE_METADATA_KEY, @@ -40,34 +42,95 @@ export class Container { this.ensureScope(this.globalContext); } - public registerRootModule(moduleClass: Constructor): void { - this.rootModuleName = moduleClass.name; - this.registerModule(moduleClass); + public async registerRootModule( + module: Constructor | DynamicModule, + ): Promise { + const moduleClass = (module as DynamicModule).module || module; + this.rootModuleName = (moduleClass as Constructor).name; + await this.registerModule(module); } public getRootModuleName(): string | undefined { return this.rootModuleName; } - public registerModule(moduleClass: Constructor): void { - const options: ModuleOptions | undefined = Reflect.getMetadata( + public async registerModule( + module: + | Constructor + | DynamicModule + | Promise + | ForwardRefFn, + ): Promise { + const unwrappedModule = (await (typeof module === "object" && + module !== null && + "forwardRef" in (module as object) + ? ( + module as { forwardRef: () => Constructor | DynamicModule } + ).forwardRef() + : module)) as Constructor | DynamicModule; + + const isDynamic = + typeof unwrappedModule === "object" && "module" in unwrappedModule; + const moduleClass = isDynamic + ? (unwrappedModule as DynamicModule).module + : (unwrappedModule as Constructor); + const dynamicOptions: ModuleOptions = isDynamic + ? (unwrappedModule as DynamicModule) + : {}; + + const metadataOptions: ModuleOptions | undefined = Reflect.getMetadata( MODULE_METADATA_KEY, moduleClass, ); - if (!options) return; + + if (!metadataOptions && !isDynamic) return; const moduleName = moduleClass.name; if (this.moduleOptions.has(moduleName)) return; + const rawImports = [ + ...(metadataOptions?.imports || []), + ...(dynamicOptions?.imports || []), + ]; + + const resolvedImports = await Promise.all( + rawImports.map(async (imp) => { + return await (typeof imp === "object" && + imp !== null && + "forwardRef" in (imp as object) + ? ( + imp as { + forwardRef: () => Constructor | DynamicModule; + } + ).forwardRef() + : imp); + }), + ); + + const options: ModuleOptions = { + ...metadataOptions, + ...dynamicOptions, + imports: resolvedImports, + providers: [ + ...(metadataOptions?.providers || []), + ...(dynamicOptions?.providers || []), + ], + exports: [ + ...(metadataOptions?.exports || []), + ...(dynamicOptions?.exports || []), + ], + }; + this.moduleOptions.set(moduleName, options); this.ensureScope(moduleName); - if (options.imports) { - for (const imported of options.imports) { - this.registerModule( - this.unwrapToken(imported as ProviderToken), - ); - } + for (const imported of resolvedImports) { + await this.registerModule( + imported as + | Constructor + | DynamicModule + | Promise, + ); } if (options.providers) { @@ -292,15 +355,16 @@ export class Container { const options = this.moduleOptions.get(context); if (options?.imports) { for (const imported of options.imports) { - const unwrappedImport = this.unwrapToken( - imported as ProviderToken, - ); - const importedProviders = this.providers.get( - unwrappedImport.name, - ); - const importedOptions = this.moduleOptions.get( - unwrappedImport.name, - ); + const importedModule = + (imported as DynamicModule).module || + (imported as Constructor); + + if (typeof importedModule !== "function") continue; + + const moduleName = importedModule.name; + const importedProviders = this.providers.get(moduleName); + const importedOptions = this.moduleOptions.get(moduleName); + if ( importedProviders?.has(unwrappedToken) && importedOptions?.exports?.some( @@ -310,7 +374,7 @@ export class Container { ) { return importedProviders.get(unwrappedToken); } - if (unwrappedToken === unwrappedImport) { + if (unwrappedToken === importedModule) { return importedProviders?.get(unwrappedToken); } } diff --git a/src/container/types.ts b/src/container/types.ts index 53213f6..532ad2d 100644 --- a/src/container/types.ts +++ b/src/container/types.ts @@ -53,8 +53,17 @@ export interface InjectableOptions { scope?: ProviderScope; } +export interface DynamicModule extends ModuleOptions { + module: Constructor; +} + export interface ModuleOptions { - imports?: (Constructor | ForwardRefFn)[]; + imports?: ( + | Constructor + | DynamicModule + | Promise + | ForwardRefFn + )[]; providers?: Provider[]; exports?: ProviderToken[]; } diff --git a/src/factory/AlveoFactory.ts b/src/factory/AlveoFactory.ts index 33d6d0a..cefab83 100644 --- a/src/factory/AlveoFactory.ts +++ b/src/factory/AlveoFactory.ts @@ -1,4 +1,5 @@ import { Container } from "../container/Container"; +import type { DynamicModule } from "../container/types"; import type { Constructor } from "../types"; import { AlveoApplication } from "./AlveoApplication"; @@ -13,11 +14,13 @@ export class AlveoFactoryStatic { * @param rootModule The root module of the application. * @returns A promise that resolves to an AlveoApplication instance. */ - public async create(rootModule: Constructor): Promise { + public async create( + rootModule: Constructor | DynamicModule, + ): Promise { const container = new Container(); // 1. Register the module tree starting from the root module - container.registerRootModule(rootModule); + await container.registerRootModule(rootModule); // 2. Resolve all providers (this builds the graph and instantiates singletons) await container.resolveAll(); diff --git a/test/container.test.ts b/test/container.test.ts index be9ef91..9b79ab8 100644 --- a/test/container.test.ts +++ b/test/container.test.ts @@ -141,7 +141,7 @@ describe("Alveo Container & DI", () => { // We use a fresh container to test concurrent resolution // bypassing the sequential resolveAll() of AlveoFactory.create() const container = new Container(); - container.registerRootModule(RootModule); + await container.registerRootModule(RootModule); const [val1, val2] = await Promise.all([ container.get(TOKEN), @@ -308,4 +308,69 @@ describe("Alveo Container & DI", () => { expect(aliased).toBe(real); expect((aliased as RealService).ok()).toBe(true); }); + + test("should support Synchronous Dynamic Modules", async () => { + @Injectable() + class DynamicService { + constructor(@Inject("CONFIG") public readonly config: string) {} + } + + @Module({}) + class DynamicModule { + static forRoot(config: string) { + return { + module: DynamicModule, + providers: [ + DynamicService, + { provide: "CONFIG", useValue: config }, + ], + exports: [DynamicService], + }; + } + } + + @Module({ + imports: [DynamicModule.forRoot("dynamic_config")], + }) + class AppModule {} + + const app = await AlveoFactory.create(AppModule); + const service = await app.get(DynamicService); + + expect(service.config).toBe("dynamic_config"); + }); + + test("should support Asynchronous Dynamic Modules", async () => { + @Injectable() + class AsyncDynamicService { + constructor( + @Inject("ASYNC_CONFIG") public readonly config: string, + ) {} + } + + @Module({}) + class AsyncDynamicModule { + static async forRoot(config: string) { + await new Promise((resolve) => setTimeout(resolve, 10)); + return { + module: AsyncDynamicModule, + providers: [ + AsyncDynamicService, + { provide: "ASYNC_CONFIG", useValue: config }, + ], + exports: [AsyncDynamicService], + }; + } + } + + @Module({ + imports: [AsyncDynamicModule.forRoot("async_dynamic_config")], + }) + class AppModule {} + + const app = await AlveoFactory.create(AppModule); + const service = await app.get(AsyncDynamicService); + + expect(service.config).toBe("async_dynamic_config"); + }); });