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:
@@ -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
311
test/container.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user