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 { 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,35 +42,96 @@ 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) {
|
||||||
for (const provider of options.providers) {
|
for (const provider of 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user