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:
@@ -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 {
|
|
||||||
if (process.env.ALVEO_CONTAINER_DEBUG === "true") {
|
|
||||||
console.debug(...args);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private unwrapToken<T>(token: ProviderToken<T>): T {
|
||||||
|
if (
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/container/forwardRef.ts
Normal file
21
src/container/forwardRef.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user