feat: implement Dynamic Modules support (sync and async)

Allows modules to be configured at runtime via static methods returning DynamicModule objects, similar to NestJS forRoot/register patterns.
This commit is contained in:
M1000fr
2026-01-11 17:18:15 +01:00
parent 7452f21450
commit deb9e704fb
4 changed files with 167 additions and 26 deletions

View File

@@ -2,8 +2,10 @@ import "reflect-metadata";
import { CircularDependencyError, ProviderNotFoundError } from "../errors"; import { CircularDependencyError, ProviderNotFoundError } from "../errors";
import { ContextualModuleRef, ModuleRef } from "../module/ModuleRef"; import { ContextualModuleRef, ModuleRef } from "../module/ModuleRef";
import type { Constructor } from "../types"; import type { Constructor } from "../types";
import type { ForwardRefFn } from "./forwardRef";
import { import {
type BaseProvider, type BaseProvider,
type DynamicModule,
INJECT_METADATA_KEY, INJECT_METADATA_KEY,
INJECTABLE_METADATA_KEY, INJECTABLE_METADATA_KEY,
MODULE_METADATA_KEY, MODULE_METADATA_KEY,
@@ -40,34 +42,95 @@ export class Container {
this.ensureScope(this.globalContext); this.ensureScope(this.globalContext);
} }
public registerRootModule(moduleClass: Constructor): void { public async registerRootModule(
this.rootModuleName = moduleClass.name; module: Constructor | DynamicModule,
this.registerModule(moduleClass); ): Promise<void> {
const moduleClass = (module as DynamicModule).module || module;
this.rootModuleName = (moduleClass as Constructor).name;
await this.registerModule(module);
} }
public getRootModuleName(): string | undefined { public getRootModuleName(): string | undefined {
return this.rootModuleName; return this.rootModuleName;
} }
public registerModule(moduleClass: Constructor): void { public async registerModule(
const options: ModuleOptions | undefined = Reflect.getMetadata( module:
| Constructor
| DynamicModule
| Promise<DynamicModule>
| ForwardRefFn<Constructor>,
): Promise<void> {
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, MODULE_METADATA_KEY,
moduleClass, moduleClass,
); );
if (!options) return;
if (!metadataOptions && !isDynamic) return;
const moduleName = moduleClass.name; const moduleName = moduleClass.name;
if (this.moduleOptions.has(moduleName)) return; 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.moduleOptions.set(moduleName, options);
this.ensureScope(moduleName); this.ensureScope(moduleName);
if (options.imports) { for (const imported of resolvedImports) {
for (const imported of options.imports) { await this.registerModule(
this.registerModule( imported as
this.unwrapToken(imported as ProviderToken<Constructor>), | Constructor
); | DynamicModule
} | Promise<DynamicModule>,
);
} }
if (options.providers) { if (options.providers) {
@@ -292,15 +355,16 @@ export class Container {
const options = this.moduleOptions.get(context); const options = this.moduleOptions.get(context);
if (options?.imports) { if (options?.imports) {
for (const imported of options.imports) { for (const imported of options.imports) {
const unwrappedImport = this.unwrapToken( const importedModule =
imported as ProviderToken<Constructor>, (imported as DynamicModule).module ||
); (imported as Constructor);
const importedProviders = this.providers.get(
unwrappedImport.name, if (typeof importedModule !== "function") continue;
);
const importedOptions = this.moduleOptions.get( const moduleName = importedModule.name;
unwrappedImport.name, const importedProviders = this.providers.get(moduleName);
); const importedOptions = this.moduleOptions.get(moduleName);
if ( if (
importedProviders?.has(unwrappedToken) && importedProviders?.has(unwrappedToken) &&
importedOptions?.exports?.some( importedOptions?.exports?.some(
@@ -310,7 +374,7 @@ export class Container {
) { ) {
return importedProviders.get(unwrappedToken); return importedProviders.get(unwrappedToken);
} }
if (unwrappedToken === unwrappedImport) { if (unwrappedToken === importedModule) {
return importedProviders?.get(unwrappedToken); return importedProviders?.get(unwrappedToken);
} }
} }

View File

@@ -53,8 +53,17 @@ export interface InjectableOptions {
scope?: ProviderScope; scope?: ProviderScope;
} }
export interface DynamicModule extends ModuleOptions {
module: Constructor;
}
export interface ModuleOptions { export interface ModuleOptions {
imports?: (Constructor | ForwardRefFn<Constructor>)[]; imports?: (
| Constructor
| DynamicModule
| Promise<DynamicModule>
| ForwardRefFn<Constructor>
)[];
providers?: Provider[]; providers?: Provider[];
exports?: ProviderToken[]; exports?: ProviderToken[];
} }

View File

@@ -1,4 +1,5 @@
import { Container } from "../container/Container"; import { Container } from "../container/Container";
import type { DynamicModule } from "../container/types";
import type { Constructor } from "../types"; import type { Constructor } from "../types";
import { AlveoApplication } from "./AlveoApplication"; import { AlveoApplication } from "./AlveoApplication";
@@ -13,11 +14,13 @@ export class AlveoFactoryStatic {
* @param rootModule The root module of the application. * @param rootModule The root module of the application.
* @returns A promise that resolves to an AlveoApplication instance. * @returns A promise that resolves to an AlveoApplication instance.
*/ */
public async create(rootModule: Constructor): Promise<AlveoApplication> { public async create(
rootModule: Constructor | DynamicModule,
): Promise<AlveoApplication> {
const container = new Container(); const container = new Container();
// 1. Register the module tree starting from the root module // 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) // 2. Resolve all providers (this builds the graph and instantiates singletons)
await container.resolveAll(); await container.resolveAll();

View File

@@ -141,7 +141,7 @@ describe("Alveo Container & DI", () => {
// We use a fresh container to test concurrent resolution // We use a fresh container to test concurrent resolution
// bypassing the sequential resolveAll() of AlveoFactory.create() // bypassing the sequential resolveAll() of AlveoFactory.create()
const container = new Container(); const container = new Container();
container.registerRootModule(RootModule); await container.registerRootModule(RootModule);
const [val1, val2] = await Promise.all([ const [val1, val2] = await Promise.all([
container.get(TOKEN), container.get(TOKEN),
@@ -308,4 +308,69 @@ describe("Alveo Container & DI", () => {
expect(aliased).toBe(real); expect(aliased).toBe(real);
expect((aliased as RealService).ok()).toBe(true); 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");
});
}); });