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:
@@ -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<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) {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user