feat: enhance asynchronous provider support and fix concurrent resolution bug

Allows factory providers to be asynchronous and ensures concurrent requests for the same singleton await the same promise instead of triggering circular dependency errors.
This commit is contained in:
M1000fr
2026-01-11 17:11:41 +01:00
parent aa8493baf6
commit 536fcfc336
2 changed files with 399 additions and 47 deletions

View File

@@ -30,6 +30,10 @@ export class Container {
private readonly moduleOptions = new Map<string, ModuleOptions>();
private readonly resolving = new Set<ProviderToken>();
private readonly proxies = new Map<string, Map<ProviderToken, unknown>>();
private readonly pendingResolutions = new Map<
string,
Map<ProviderToken, Promise<unknown>>
>();
private rootModuleName?: string;
constructor(private readonly globalContext = "global") {
@@ -160,6 +164,13 @@ export class Container {
if (existing !== undefined) {
return existing as T;
}
const pending = this.pendingResolutions
.get(scopeName)
?.get(unwrappedToken);
if (pending) {
return pending as Promise<T>;
}
}
if (this.resolving.has(unwrappedToken)) {
@@ -185,60 +196,87 @@ export class Container {
throw new CircularDependencyError(stack);
}
this.resolving.add(unwrappedToken);
const performResolution = async (): Promise<T> => {
this.resolving.add(unwrappedToken);
try {
let instance: T;
try {
let instance: T;
if (provider.useValue !== undefined) {
instance = provider.useValue as T;
} else if (provider.useFactory) {
const dependencies = await Promise.all(
(provider.inject ?? []).map((dep) =>
this.resolve(dep, provider.moduleName ?? context),
),
);
instance = (await provider.useFactory(...dependencies)) as T;
} else if (provider.useExisting) {
instance = await this.resolve<T>(
provider.useExisting as ProviderToken<T>,
provider.moduleName ?? context,
);
} else if (provider.useClass) {
const targetClass = provider.useClass;
const paramTypes: ParamType[] =
Reflect.getMetadata("design:paramtypes", targetClass) || [];
const injectTokens: ProviderToken[] =
Reflect.getMetadata(INJECT_METADATA_KEY, targetClass) || [];
if (provider.useValue !== undefined) {
instance = provider.useValue as T;
} else if (provider.useFactory) {
const dependencies = await Promise.all(
(provider.inject ?? []).map((dep) =>
this.resolve(dep, provider.moduleName ?? context),
),
);
instance = (await provider.useFactory(
...dependencies,
)) as T;
} else if (provider.useExisting) {
instance = await this.resolve<T>(
provider.useExisting as ProviderToken<T>,
provider.moduleName ?? context,
);
} else if (provider.useClass) {
const targetClass = provider.useClass;
const paramTypes: ParamType[] =
Reflect.getMetadata("design:paramtypes", targetClass) ||
[];
const injectTokens: ProviderToken[] =
Reflect.getMetadata(INJECT_METADATA_KEY, targetClass) ||
[];
const dependencies = await Promise.all(
paramTypes.map(async (paramType, index) => {
const depToken =
injectTokens[index] || (paramType as ProviderToken);
return this.resolve(
depToken as ProviderToken<unknown>,
provider.moduleName ?? context,
);
}),
);
const dependencies = await Promise.all(
paramTypes.map(async (paramType, index) => {
const depToken =
injectTokens[index] ||
(paramType as ProviderToken);
return this.resolve(
depToken as ProviderToken<unknown>,
provider.moduleName ?? context,
);
}),
);
instance = new targetClass(...dependencies) as T;
} else {
throw new Error(
`Invalid provider configuration for token ${String(token)}`,
);
instance = new targetClass(...dependencies) as T;
} else {
throw new Error(
`Invalid provider configuration for token ${String(token)}`,
);
}
if (provider.scope === "singleton") {
const scopeName = provider.moduleName ?? context;
this.ensureScope(scopeName);
this.instances
.get(scopeName)!
.set(unwrappedToken, instance);
}
return instance;
} finally {
this.resolving.delete(unwrappedToken);
if (provider.scope === "singleton") {
const scopeName = provider.moduleName ?? context;
this.pendingResolutions
.get(scopeName)
?.delete(unwrappedToken);
}
}
};
if (provider.scope === "singleton") {
const scopeName = provider.moduleName ?? context;
this.ensureScope(scopeName);
this.instances.get(scopeName)!.set(provider.token, instance);
}
return instance;
} finally {
this.resolving.delete(unwrappedToken);
if (provider.scope === "singleton") {
const scopeName = provider.moduleName ?? context;
this.ensureScope(scopeName);
const promise = performResolution();
this.pendingResolutions
.get(scopeName)!
.set(unwrappedToken, promise);
return promise;
}
return performResolution();
}
private findProvider(
@@ -351,6 +389,9 @@ export class Container {
if (!this.proxies.has(name)) {
this.proxies.set(name, new Map());
}
if (!this.pendingResolutions.has(name)) {
this.pendingResolutions.set(name, new Map());
}
}
private unwrapToken<T>(token: ProviderToken<T>): T {

311
test/container.test.ts Normal file
View File

@@ -0,0 +1,311 @@
import { describe, expect, test } from "bun:test";
import "reflect-metadata";
import {
AlveoFactory,
CircularDependencyError,
Container,
forwardRef,
Inject,
Injectable,
Module,
ProviderNotFoundError,
} from "../index";
describe("Alveo Container & DI", () => {
test("should resolve a simple singleton service", async () => {
@Injectable()
class SimpleService {
getValue() {
return "hello";
}
}
@Module({
providers: [SimpleService],
})
class RootModule {}
const app = await AlveoFactory.create(RootModule);
const service = await app.get(SimpleService);
expect(service).toBeInstanceOf(SimpleService);
expect(service.getValue()).toBe("hello");
});
test("should inject dependencies via constructor", async () => {
@Injectable()
class Dependency {
name = "dep";
}
@Injectable()
class MainService {
constructor(public readonly dep: Dependency) {}
}
@Module({
providers: [Dependency, MainService],
})
class RootModule {}
const app = await AlveoFactory.create(RootModule);
const main = await app.get(MainService);
expect(main.dep).toBeInstanceOf(Dependency);
expect(main.dep.name).toBe("dep");
});
test("should support Value Providers", async () => {
const TOKEN = Symbol("CONFIG");
@Injectable()
class Service {
constructor(
@Inject(TOKEN) public readonly config: { apiKey: string },
) {}
}
@Module({
providers: [
Service,
{ provide: TOKEN, useValue: { apiKey: "123" } },
],
})
class RootModule {}
const app = await AlveoFactory.create(RootModule);
const service = await app.get(Service);
expect(service.config.apiKey).toBe("123");
});
test("should support Factory Providers", async () => {
const TOKEN = "FACTORY";
@Module({
providers: [
{
provide: TOKEN,
useFactory: () => "dynamic_value",
},
],
})
class RootModule {}
const app = await AlveoFactory.create(RootModule);
const value = await app.get(TOKEN);
expect(value).toBe("dynamic_value");
});
test("should support Asynchronous Factory Providers", async () => {
const TOKEN = "ASYNC_FACTORY";
@Module({
providers: [
{
provide: TOKEN,
useFactory: async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
return "async_value";
},
},
],
})
class RootModule {}
const app = await AlveoFactory.create(RootModule);
const value = await app.get(TOKEN);
expect(value).toBe("async_value");
});
test("should handle concurrent resolution of the same async provider", async () => {
const TOKEN = "CONCURRENT_ASYNC";
let callCount = 0;
@Module({
providers: [
{
provide: TOKEN,
useFactory: async () => {
callCount++;
await new Promise((resolve) => setTimeout(resolve, 20));
return { id: Math.random() };
},
},
],
})
class RootModule {}
// We use a fresh container to test concurrent resolution
// bypassing the sequential resolveAll() of AlveoFactory.create()
const container = new Container();
container.registerRootModule(RootModule);
const [val1, val2] = await Promise.all([
container.get(TOKEN),
container.get(TOKEN),
]);
expect(val1).toBe(val2);
expect(callCount).toBe(1);
});
test("should handle Module imports and exports", async () => {
@Injectable()
class SharedService {
identify() {
return "shared";
}
}
@Module({
providers: [SharedService],
exports: [SharedService],
})
class LibModule {}
@Module({
imports: [LibModule],
})
class AppModule {}
const app = await AlveoFactory.create(AppModule);
const service = await app.get(SharedService);
expect(service.identify()).toBe("shared");
});
test("should throw ProviderNotFoundError when dependency is missing", async () => {
@Injectable()
class MissingDep {}
@Injectable()
class Target {
constructor(_dep: MissingDep) {}
}
@Module({
providers: [Target], // MissingDep is not provided
})
class RootModule {}
expect(AlveoFactory.create(RootModule)).rejects.toThrow(
ProviderNotFoundError,
);
});
test("should detect circular dependencies without forwardRef", async () => {
@Injectable()
class A {
constructor(_b: unknown) {}
}
@Injectable()
class B {
constructor(_a: A) {}
}
// Manually define metadata to simulate circular dep without forwardRef
Reflect.defineMetadata("design:paramtypes", [Object], A);
Reflect.defineMetadata("design:paramtypes", [A], B);
Reflect.defineMetadata("alveo:inject", [B], A);
@Module({
providers: [A, B],
})
class RootModule {}
expect(AlveoFactory.create(RootModule)).rejects.toThrow(
CircularDependencyError,
);
});
test("should resolve circular dependencies with forwardRef", async () => {
@Injectable()
class ServiceA {
constructor(
@Inject(forwardRef(() => ServiceB))
public readonly serviceB: { name: () => string },
) {}
name() {
return "A";
}
}
@Injectable()
class ServiceB {
constructor(
@Inject(forwardRef(() => ServiceA))
public readonly serviceA: { name: () => string },
) {}
name() {
return "B";
}
}
@Module({
providers: [ServiceA, ServiceB],
})
class RootModule {}
const app = await AlveoFactory.create(RootModule);
const a = await app.get(ServiceA);
const b = await app.get(ServiceB);
expect(a.serviceB.name()).toBe("B");
expect(b.serviceA.name()).toBe("A");
});
test("should call lifecycle hooks", async () => {
let initCalled = false;
let destroyCalled = false;
@Injectable()
class LifecycleService {
async onModuleInit() {
initCalled = true;
}
async onModuleDestroy() {
destroyCalled = true;
}
}
@Module({
providers: [LifecycleService],
})
class RootModule {}
const app = await AlveoFactory.create(RootModule);
expect(initCalled).toBe(true);
await app.close();
expect(destroyCalled).toBe(true);
});
test("should support existing providers (aliases)", async () => {
const ALIAS = "ALIAS_TOKEN";
@Injectable()
class RealService {
ok() {
return true;
}
}
@Module({
providers: [
RealService,
{ provide: ALIAS, useExisting: RealService },
],
})
class RootModule {}
const app = await AlveoFactory.create(RootModule);
const real = await app.get(RealService);
const aliased = await app.get(ALIAS);
expect(aliased).toBe(real);
expect((aliased as RealService).ok()).toBe(true);
});
});