feat: implement forwardRef support for services and modules

This adds support for circular dependencies using forwardRef and Proxy-based resolution.
This commit is contained in:
M1000fr
2026-01-11 17:03:59 +01:00
parent 00fb21c558
commit aa8493baf6
4 changed files with 130 additions and 48 deletions

View File

@@ -29,6 +29,7 @@ export class Container {
private readonly instances = new Map<string, Map<ProviderToken, unknown>>(); private readonly instances = new Map<string, Map<ProviderToken, unknown>>();
private readonly moduleOptions = new Map<string, ModuleOptions>(); private readonly moduleOptions = new Map<string, ModuleOptions>();
private readonly resolving = new Set<ProviderToken>(); private readonly resolving = new Set<ProviderToken>();
private readonly proxies = new Map<string, Map<ProviderToken, unknown>>();
private rootModuleName?: string; private rootModuleName?: string;
constructor(private readonly globalContext = "global") { constructor(private readonly globalContext = "global") {
@@ -59,7 +60,9 @@ export class Container {
if (options.imports) { if (options.imports) {
for (const imported of options.imports) { for (const imported of options.imports) {
this.registerModule(imported); this.registerModule(
this.unwrapToken(imported as ProviderToken<Constructor>),
);
} }
} }
@@ -120,49 +123,69 @@ export class Container {
token: ProviderToken<T>, token: ProviderToken<T>,
moduleName?: string, moduleName?: string,
): Promise<T> { ): Promise<T> {
const unwrappedToken = this.unwrapToken(token) as ProviderToken<T>;
const context = moduleName ?? this.globalContext; const context = moduleName ?? this.globalContext;
if (token === ModuleRef) { if (unwrappedToken === ModuleRef) {
return new ContextualModuleRef(this, context) as T; return new ContextualModuleRef(this, context) as T;
} }
let provider = this.findProvider(token, context); let provider = this.findProvider(unwrappedToken, context);
if ( if (
!provider && !provider &&
context === this.globalContext && context === this.globalContext &&
this.rootModuleName this.rootModuleName
) { ) {
provider = this.findProvider(token, this.rootModuleName); provider = this.findProvider(unwrappedToken, this.rootModuleName);
} }
if (!provider) { if (!provider) {
throw new ProviderNotFoundError( throw new ProviderNotFoundError(
typeof token === "function" ? token.name : String(token), typeof unwrappedToken === "function"
? unwrappedToken.name
: String(unwrappedToken),
context, context,
); );
} }
if (provider.scope === "singleton") { if (provider.scope === "singleton") {
const existing = this.instances const scopeName = provider.moduleName ?? context;
.get(provider.moduleName ?? context) const proxy = this.proxies.get(scopeName)?.get(unwrappedToken);
?.get(provider.token); if (proxy !== undefined) {
return proxy as T;
}
const existing = this.instances.get(scopeName)?.get(unwrappedToken);
if (existing !== undefined) { if (existing !== undefined) {
return existing as T; return existing as T;
} }
} }
if (this.resolving.has(token)) { if (this.resolving.has(unwrappedToken)) {
if (
typeof token === "object" &&
token !== null &&
"forwardRef" in token
) {
const scopeName = provider.moduleName ?? context;
const proxy = this.createProxy(unwrappedToken, scopeName);
this.ensureScope(scopeName);
this.proxies.get(scopeName)!.set(unwrappedToken, proxy);
return proxy as T;
}
const stack = [...this.resolving].map((item) => const stack = [...this.resolving].map((item) =>
typeof item === "function" ? item.name : String(item), typeof item === "function" ? item.name : String(item),
); );
stack.push( stack.push(
typeof token === "function" ? token.name : String(token), typeof unwrappedToken === "function"
? unwrappedToken.name
: String(unwrappedToken),
); );
throw new CircularDependencyError(stack); throw new CircularDependencyError(stack);
} }
this.resolving.add(token); this.resolving.add(unwrappedToken);
try { try {
let instance: T; let instance: T;
@@ -188,25 +211,10 @@ export class Container {
const injectTokens: ProviderToken[] = const injectTokens: ProviderToken[] =
Reflect.getMetadata(INJECT_METADATA_KEY, targetClass) || []; Reflect.getMetadata(INJECT_METADATA_KEY, targetClass) || [];
this.debug(`[Container] Instantiating ${targetClass.name}`);
this.debug(
"[Container] ParamTypes:",
paramTypes.map((p) => p?.name || String(p)),
);
this.debug(
"[Container] InjectTokens:",
injectTokens.map((token) =>
token ? String(token) : "undefined",
),
);
const dependencies = await Promise.all( const dependencies = await Promise.all(
paramTypes.map(async (paramType, index) => { paramTypes.map(async (paramType, index) => {
const depToken = const depToken =
injectTokens[index] || (paramType as ProviderToken); injectTokens[index] || (paramType as ProviderToken);
this.debug(
`[Container] Resolving dependency ${index}: ${String(depToken)}`,
);
return this.resolve( return this.resolve(
depToken as ProviderToken<unknown>, depToken as ProviderToken<unknown>,
provider.moduleName ?? context, provider.moduleName ?? context,
@@ -229,7 +237,7 @@ export class Container {
return instance; return instance;
} finally { } finally {
this.resolving.delete(token); this.resolving.delete(unwrappedToken);
} }
} }
@@ -237,39 +245,50 @@ export class Container {
token: ProviderToken, token: ProviderToken,
context: string, context: string,
): ResolvedProvider | undefined { ): ResolvedProvider | undefined {
const unwrappedToken = this.unwrapToken(token) as ProviderToken;
const moduleProviders = this.providers.get(context); const moduleProviders = this.providers.get(context);
if (moduleProviders?.has(token)) { if (moduleProviders?.has(unwrappedToken)) {
return moduleProviders.get(token); return moduleProviders.get(unwrappedToken);
} }
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 importedProviders = this.providers.get(imported.name); const unwrappedImport = this.unwrapToken(
const importedOptions = this.moduleOptions.get(imported.name); imported as ProviderToken<Constructor>,
);
const importedProviders = this.providers.get(
unwrappedImport.name,
);
const importedOptions = this.moduleOptions.get(
unwrappedImport.name,
);
if ( if (
importedProviders?.has(token) && importedProviders?.has(unwrappedToken) &&
importedOptions?.exports?.includes(token) importedOptions?.exports?.some(
(exportToken) =>
this.unwrapToken(exportToken) === unwrappedToken,
)
) { ) {
return importedProviders.get(token); return importedProviders.get(unwrappedToken);
} }
if (token === imported) { if (unwrappedToken === unwrappedImport) {
return importedProviders?.get(token); return importedProviders?.get(unwrappedToken);
} }
} }
} }
if (context !== this.globalContext) { if (context !== this.globalContext) {
const globalProviders = this.providers.get(this.globalContext); const globalProviders = this.providers.get(this.globalContext);
if (globalProviders?.has(token)) { if (globalProviders?.has(unwrappedToken)) {
return globalProviders.get(token); return globalProviders.get(unwrappedToken);
} }
} }
if (context === this.globalContext && this.rootModuleName) { if (context === this.globalContext && this.rootModuleName) {
const rootProviders = this.providers.get(this.rootModuleName); const rootProviders = this.providers.get(this.rootModuleName);
if (rootProviders?.has(token)) { if (rootProviders?.has(unwrappedToken)) {
return rootProviders.get(token); return rootProviders.get(unwrappedToken);
} }
} }
@@ -294,7 +313,7 @@ export class Container {
} }
const base = { const base = {
token: provider.provide, token: this.unwrapToken(provider.provide) as ProviderToken,
scope: provider.scope ?? "singleton", scope: provider.scope ?? "singleton",
moduleName, moduleName,
}; };
@@ -329,11 +348,46 @@ export class Container {
if (!this.instances.has(name)) { if (!this.instances.has(name)) {
this.instances.set(name, new Map()); this.instances.set(name, new Map());
} }
if (!this.proxies.has(name)) {
this.proxies.set(name, new Map());
}
} }
private debug(...args: unknown[]): void { private unwrapToken<T>(token: ProviderToken<T>): T {
if (process.env.ALVEO_CONTAINER_DEBUG === "true") { if (
console.debug(...args); typeof token === "object" &&
} token !== null &&
"forwardRef" in token
) {
return (token as { forwardRef: () => T }).forwardRef();
}
return token as T;
}
private createProxy<T>(token: ProviderToken<T>, context: string): T {
const self = this;
return new Proxy({} as object, {
get(_target, prop, receiver) {
if (prop === "then") return undefined;
const instance = self.instances.get(context)?.get(token);
if (!instance) {
throw new Error(
`Circular dependency instance for ${
typeof token === "function"
? token.name
: String(token)
} is not yet available. It might be accessed too early (e.g. in a constructor or during static initialization).`,
);
}
const value = Reflect.get(instance as object, prop, receiver);
return typeof value === "function"
? value.bind(instance)
: value;
},
has(_target, prop) {
const instance = self.instances.get(context)?.get(token);
return instance ? Reflect.has(instance as object, prop) : false;
},
}) as T;
} }
} }

View File

@@ -0,0 +1,21 @@
/**
* Interface for a function that returns a type.
*/
export type ForwardReference<T = unknown> = () => T;
/**
* Interface for the object returned by forwardRef.
*/
export interface ForwardRefFn<T = unknown> {
forwardRef: ForwardReference<T>;
}
/**
* Allows to refer to a reference which is not yet defined.
* Useful for circular dependencies between classes or modules.
*
* @param fn A function that returns the reference
*/
export function forwardRef<T>(fn: ForwardReference<T>): ForwardRefFn<T> {
return { forwardRef: fn };
}

View File

@@ -1,8 +1,13 @@
import type { Constructor, Type } from "../types"; import type { Constructor, Type } from "../types";
import type { ForwardRefFn } from "./forwardRef";
export type ProviderScope = "singleton" | "transient"; export type ProviderScope = "singleton" | "transient";
export type ProviderToken<T = unknown> = Type<T> | string | symbol; export type ProviderToken<T = unknown> =
| Type<T>
| string
| symbol
| ForwardRefFn<Type<T>>;
export interface BaseProvider<T = unknown> { export interface BaseProvider<T = unknown> {
provide: ProviderToken<T>; provide: ProviderToken<T>;
@@ -49,7 +54,7 @@ export interface InjectableOptions {
} }
export interface ModuleOptions { export interface ModuleOptions {
imports?: Constructor[]; imports?: (Constructor | ForwardRefFn<Constructor>)[];
providers?: Provider[]; providers?: Provider[];
exports?: ProviderToken[]; exports?: ProviderToken[];
} }

View File

@@ -1,4 +1,6 @@
export { Container } from "./container/Container"; export { Container } from "./container/Container";
export type { ForwardReference, ForwardRefFn } from "./container/forwardRef";
export { forwardRef } from "./container/forwardRef";
export type { export type {
ClassProvider, ClassProvider,
ExistingProvider, ExistingProvider,