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 { 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,35 +42,96 @@ 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<void> {
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<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,
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<Constructor>),
for (const imported of resolvedImports) {
await this.registerModule(
imported as
| Constructor
| DynamicModule
| Promise<DynamicModule>,
);
}
}
if (options.providers) {
for (const provider of 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<Constructor>,
);
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);
}
}

View File

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

View File

@@ -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<AlveoApplication> {
public async create(
rootModule: Constructor | DynamicModule,
): Promise<AlveoApplication> {
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();

View File

@@ -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");
});
});