diff --git a/src/container/Container.ts b/src/container/Container.ts index 3e221df..1701f46 100644 --- a/src/container/Container.ts +++ b/src/container/Container.ts @@ -30,6 +30,10 @@ export class Container { private readonly moduleOptions = new Map(); private readonly resolving = new Set(); private readonly proxies = new Map>(); + private readonly pendingResolutions = new Map< + string, + Map> + >(); 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; + } } if (this.resolving.has(unwrappedToken)) { @@ -185,60 +196,87 @@ export class Container { throw new CircularDependencyError(stack); } - this.resolving.add(unwrappedToken); + const performResolution = async (): Promise => { + 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( - provider.useExisting as ProviderToken, - 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( + provider.useExisting as ProviderToken, + 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, - 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, + 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(token: ProviderToken): T { diff --git a/test/container.test.ts b/test/container.test.ts new file mode 100644 index 0000000..be9ef91 --- /dev/null +++ b/test/container.test.ts @@ -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); + }); +});