@travetto/di
Version:
Dependency registration/management and injection support.
566 lines (473 loc) • 17.7 kB
text/typescript
import {
Class, Runtime, asConstructable, castTo, classConstruct, describeFunction,
asFull, castKey, TypedFunction, hasFunction
} from '@travetto/runtime';
import { MetadataRegistry, RootRegistry, ChangeEvent } from '@travetto/registry';
import { Dependency, InjectableConfig, ClassTarget, InjectableFactoryConfig } from './types';
import { InjectionError } from './error';
import { AutoCreateTarget } from './internal/types';
type TargetId = string;
type ClassId = string;
export type Resolved<T> = { config: InjectableConfig<T>, qualifier: symbol, id: string };
export type ResolutionType = 'strict' | 'loose' | 'any';
const PrimaryCandidateSymbol = Symbol.for('@travetto/di:primary');
const hasPostConstruct = hasFunction<{ postConstruct: () => Promise<unknown> }>('postConstruct');
const hasPreDestroy = hasFunction<{ preDestroy: () => Promise<unknown> }>('preDestroy');
/**
* Dependency registry
*/
class $DependencyRegistry extends MetadataRegistry<InjectableConfig> {
pendingFinalize: Class[] = [];
defaultSymbols = new Set<symbol>();
instances = new Map<TargetId, Map<symbol, unknown>>();
instancePromises = new Map<TargetId, Map<symbol, Promise<unknown>>>();
factories = new Map<TargetId, Map<Class, InjectableConfig>>();
targetToClass = new Map<TargetId, Map<symbol, string>>();
classToTarget = new Map<ClassId, Map<symbol, TargetId>>();
constructor() {
super(RootRegistry);
}
/**
* Resolve the target given a qualifier
* @param target
* @param qualifier
*/
resolveTarget<T>(target: ClassTarget<T>, qualifier?: symbol, resolution?: ResolutionType): Resolved<T> {
const qualifiers = this.targetToClass.get(target.Ⲑid) ?? new Map<symbol, string>();
let cls: string | undefined;
if (qualifier && qualifiers.has(qualifier)) {
cls = qualifiers.get(qualifier);
} else {
const resolved = [...qualifiers.keys()];
if (!qualifier) {
// If primary found
if (qualifiers.has(PrimaryCandidateSymbol)) {
qualifier = PrimaryCandidateSymbol;
} else {
// If there is only one default symbol
const filtered = resolved.filter(x => !!x).filter(x => this.defaultSymbols.has(x));
if (filtered.length === 1) {
qualifier = filtered[0];
} else if (filtered.length > 1) {
// If dealing with sub types, prioritize exact matches
const exact = this
.getCandidateTypes(castTo<Class>(target))
.filter(x => x.class === target);
if (exact.length === 1) {
qualifier = exact[0].qualifier;
} else {
if (resolution === 'any') {
qualifier = filtered[0];
} else {
throw new InjectionError('Dependency has multiple candidates', target, filtered);
}
}
}
}
}
if (!qualifier) {
throw new InjectionError('Dependency not found', target);
} else if (!qualifiers.has(qualifier)) {
if (!this.defaultSymbols.has(qualifier) && resolution === 'loose') {
console.debug('Unable to find specific dependency, falling back to general instance', { qualifier, target: target.Ⲑid });
return this.resolveTarget(target);
}
throw new InjectionError('Dependency not found', target, [qualifier]);
} else {
cls = qualifiers.get(qualifier!)!;
}
}
const config: InjectableConfig<T> = castTo(this.get(cls!));
return {
qualifier,
config,
id: (config.factory ? config.target : config.class).Ⲑid
};
}
/**
* Retrieve all dependencies
*/
async fetchDependencies(managed: InjectableConfig, deps?: Dependency[]): Promise<unknown[]> {
if (!deps || !deps.length) {
return [];
}
const promises = deps.map(async x => {
try {
return await this.getInstance(x.target, x.qualifier, x.resolution);
} catch (err) {
if (x.optional && err instanceof InjectionError && err.category === 'notfound') {
return undefined;
} else {
if (err && err instanceof Error) {
err.message = `${err.message} via=${managed.class.Ⲑid}`;
}
throw err;
}
}
});
return await Promise.all(promises);
}
/**
* Resolve all field dependencies
*/
async resolveFieldDependencies<T>(config: InjectableConfig<T>, instance: T): Promise<void> {
const keys = Object.keys(config.dependencies.fields ?? {})
.filter(k => instance[castKey<T>(k)] === undefined); // Filter out already set ones
// And auto-wire
if (keys.length) {
const deps = await this.fetchDependencies(config, keys.map(x => config.dependencies.fields[x]));
for (let i = 0; i < keys.length; i++) {
instance[castKey<T>(keys[i])] = castTo(deps[i]);
}
}
}
/**
* Actually construct an instance while resolving the dependencies
*/
async construct<T>(target: ClassTarget<T>, qualifier: symbol): Promise<T> {
const managed = this.resolveTarget(target, qualifier).config;
// Only fetch constructor values
const consValues = await this.fetchDependencies(managed, managed.dependencies.cons);
// Create instance
const inst = managed.factory ?
managed.factory(...consValues) :
classConstruct(managed.class, consValues);
// And auto-wire fields
await this.resolveFieldDependencies(managed, inst);
// If factory with field properties on the sub class
if (managed.factory) {
const resolved = this.get(asConstructable(inst).constructor);
if (resolved) {
await this.resolveFieldDependencies(resolved, inst);
}
}
// Run post construct, if it wasn't passed in, otherwise it was already created
if (hasPostConstruct(inst) && !consValues.includes(inst)) {
await inst.postConstruct();
}
return inst;
}
/**
* Create the instance
*/
async createInstance<T>(target: ClassTarget<T>, qualifier: symbol): Promise<T> {
const classId = this.resolveTarget(target, qualifier).id;
if (!this.instances.has(classId)) {
this.instances.set(classId, new Map());
this.instancePromises.set(classId, new Map());
}
if (this.instancePromises.get(classId)!.has(qualifier)) {
return castTo(this.instancePromises.get(classId)!.get(qualifier));
}
const instancePromise = this.construct(target, qualifier);
this.instancePromises.get(classId)!.set(qualifier, instancePromise);
try {
const instance = await instancePromise;
this.instances.get(classId)!.set(qualifier, instance);
return instance;
} catch (err) {
// Clear it out, don't save failed constructions
this.instancePromises.get(classId)!.delete(qualifier);
throw err;
}
}
/**
* Destroy an instance
*/
destroyInstance(cls: Class, qualifier: symbol): void {
const classId = cls.Ⲑid;
const activeInstance = this.instances.get(classId)!.get(qualifier);
if (hasPreDestroy(activeInstance)) {
activeInstance.preDestroy();
}
this.defaultSymbols.delete(qualifier);
this.instances.get(classId)!.delete(qualifier);
this.instancePromises.get(classId)!.delete(qualifier);
this.classToTarget.get(classId)!.delete(qualifier);
console.debug('On uninstall', { id: classId, qualifier: qualifier.toString(), classId });
}
override async init(): Promise<void> {
await super.init();
if (Runtime.dynamic) {
const { DependencyRegistration } = await import('../support/dynamic.injection');
DependencyRegistration.init(this);
}
// Allow for auto-creation
for (const cfg of await this.getCandidateTypes(AutoCreateTarget)) {
await this.getInstance(cfg.class, cfg.qualifier);
}
}
/**
* Handle initial installation for the entire registry
*/
override initialInstall(): Class[] {
const finalizing = this.pendingFinalize;
this.pendingFinalize = [];
for (const cls of finalizing) {
this.install(cls, { type: 'added', curr: cls });
}
return [];
}
/**
* Register a cls as pending
*/
createPending(cls: Class): Partial<InjectableConfig> {
if (!this.resolved) {
this.pendingFinalize.push(cls);
}
return {
class: cls,
enabled: true,
target: cls,
interfaces: [],
dependencies: {
fields: {},
cons: []
}
};
}
/**
* Get an instance by type and qualifier
*/
async getInstance<T>(target: ClassTarget<T>, qual?: symbol, resolution?: ResolutionType): Promise<T> {
this.verifyInitialized();
const { id: classId, qualifier } = this.resolveTarget(target, qual, resolution);
if (!this.instances.has(classId) || !this.instances.get(classId)!.has(qualifier)) {
await this.createInstance(target, qualifier); // Wait for proxy
}
return castTo(this.instances.get(classId)!.get(qualifier));
}
/**
* Get all available candidate types for the target
*/
getCandidateTypes<T, U = T>(target: Class<U>): InjectableConfig<T>[] {
const qualifiers = this.targetToClass.get(target.Ⲑid)!;
const uniqueQualifiers = qualifiers ? Array.from(new Set(qualifiers.values())) : [];
return castTo(uniqueQualifiers.map(id => this.get(id)));
}
/**
* Get candidate instances by target type, with an optional filter
*/
getCandidateInstances<T>(target: Class, predicate?: (cfg: InjectableConfig<T>) => boolean): Promise<T[]> {
const inputs = this.getCandidateTypes<T>(target).filter(x => !predicate || predicate(x));
return Promise.all(inputs.map(l => this.getInstance<T>(l.class, l.qualifier)));
}
/**
* Register a constructor with dependencies
*/
registerConstructor<T>(cls: Class<T>, dependencies?: Dependency[]): void {
const conf = this.getOrCreatePending(cls);
conf.dependencies!.cons = dependencies;
}
/**
* Register a property as a dependency
*/
registerProperty<T>(cls: Class<T>, field: string, dependency: Dependency): void {
const conf = this.getOrCreatePending(cls);
conf.dependencies!.fields[field] = dependency;
}
/**
* Register a class
*/
registerClass<T>(cls: Class<T>, pConfig: Partial<InjectableConfig<T>> = {}): void {
const config = this.getOrCreatePending(pConfig.class ?? cls);
config.enabled = pConfig.enabled ?? config.enabled;
config.class = cls;
config.qualifier = pConfig.qualifier ?? config.qualifier ?? Symbol.for(cls.Ⲑid);
if (pConfig.interfaces) {
config.interfaces?.push(...pConfig.interfaces);
}
if (pConfig.primary !== undefined) {
config.primary = pConfig.primary;
}
if (pConfig.factory) {
config.factory = pConfig.factory ?? config.factory;
}
if (pConfig.target) {
config.target = pConfig.target;
}
if (pConfig.dependencies) {
config.dependencies = {
...pConfig.dependencies,
fields: {
...pConfig.dependencies.fields
}
};
}
}
/**
* Register a factory configuration
*/
registerFactory(config: Omit<InjectableFactoryConfig, 'qualifier'> & {
id: string;
qualifier?: undefined | symbol;
fn: TypedFunction;
}): void {
const finalConfig: Partial<InjectableConfig> = {};
finalConfig.enabled = config.enabled ?? true;
finalConfig.factory = config.fn;
finalConfig.target = config.target;
finalConfig.qualifier = config.qualifier;
if (!finalConfig.qualifier) {
finalConfig.qualifier = Symbol.for(config.id);
}
if (config.primary !== undefined) {
finalConfig.primary = config.primary;
}
finalConfig.dependencies = { fields: {} };
if (config.dependencies) {
finalConfig.dependencies.cons = config.dependencies;
}
// Create mock cls for DI purposes
const fnClass = class { static Ⲑid = config.id; };
finalConfig.class = fnClass;
this.registerClass(fnClass, finalConfig);
const srcClassId = config.src.Ⲑid;
if (!this.factories.has(srcClassId)) {
this.factories.set(srcClassId, new Map());
}
this.factories.get(srcClassId)!.set(fnClass, asFull(finalConfig));
}
/**
* On Install event
*/
override onInstall<T>(cls: Class<T>, e: ChangeEvent<Class<T>>): void {
super.onInstall(cls, e);
const classId = cls.Ⲑid;
// Install factories separate from classes
if (this.factories.has(classId)) {
for (const fact of this.factories.get(classId)!.keys()) {
this.onInstall(fact, e);
}
}
}
/**
* Handle installing a class
*/
onInstallFinalize<T>(cls: Class<T>): InjectableConfig<T> {
const classId = cls.Ⲑid;
const config: InjectableConfig<T> = castTo(this.getOrCreatePending(cls));
if (!(typeof config.enabled === 'boolean' ? config.enabled : config.enabled())) {
return config; // Do not setup if disabled
}
// Allow for the factory to fulfill the target
let parentClass: Function = config.factory ? config.target : Object.getPrototypeOf(cls);
if (config.factory) {
while (describeFunction(Object.getPrototypeOf(parentClass))?.abstract) {
parentClass = Object.getPrototypeOf(parentClass);
}
if (!this.targetToClass.has(classId)) {
this.targetToClass.set(classId, new Map());
}
// Make explicitly discoverable as self
this.targetToClass.get(classId)?.set(config.qualifier, classId);
}
const parentConfig = this.get(parentClass.Ⲑid);
if (parentConfig) {
config.dependencies.fields = {
...parentConfig.dependencies!.fields,
...config.dependencies.fields
};
// collect interfaces
config.interfaces = [
...parentConfig.interfaces,
...config.interfaces
];
// Inherit cons deps if no constructor defined
if (config.dependencies.cons === undefined) {
config.dependencies.cons = parentConfig.dependencies.cons;
}
}
if (describeFunction(cls)?.abstract) { // Skip out early, only needed to inherit
return config;
}
if (!this.classToTarget.has(classId)) {
this.classToTarget.set(classId, new Map());
}
const targetClassId = config.target.Ⲑid;
if (!this.targetToClass.has(targetClassId)) {
this.targetToClass.set(targetClassId, new Map());
}
if (config.qualifier === Symbol.for(classId)) {
this.defaultSymbols.add(config.qualifier);
}
this.targetToClass.get(targetClassId)!.set(config.qualifier, classId);
this.classToTarget.get(classId)!.set(config.qualifier, targetClassId);
// If aliased
for (const el of config.interfaces) {
const elClassId = el.Ⲑid;
if (!this.targetToClass.has(elClassId)) {
this.targetToClass.set(elClassId, new Map());
}
this.targetToClass.get(elClassId)!.set(config.qualifier, classId);
this.classToTarget.get(classId)!.set(Symbol.for(elClassId), elClassId);
if (config.primary && (classId === targetClassId || config.factory)) {
this.targetToClass.get(elClassId)!.set(PrimaryCandidateSymbol, classId);
}
}
// If targeting self (default @Injectable behavior)
if ((classId === targetClassId || config.factory) && (parentConfig || describeFunction(parentClass)?.abstract)) {
const parentId = parentClass.Ⲑid;
if (!this.targetToClass.has(parentId)) {
this.targetToClass.set(parentId, new Map());
}
if (config.primary) {
this.targetToClass.get(parentId)!.set(PrimaryCandidateSymbol, classId);
}
this.targetToClass.get(parentId)!.set(config.qualifier, classId);
this.classToTarget.get(classId)!.set(config.qualifier, parentId);
}
if (config.primary) {
if (!this.targetToClass.has(classId)) {
this.targetToClass.set(classId, new Map());
}
this.targetToClass.get(classId)!.set(PrimaryCandidateSymbol, classId);
if (config.factory) {
this.targetToClass.get(targetClassId)!.set(PrimaryCandidateSymbol, classId);
}
// Register primary if only one interface provided and no parent config
if (config.interfaces.length === 1 && !parentConfig) {
const [primaryInterface] = config.interfaces;
const primaryClassId = primaryInterface.Ⲑid;
if (!this.targetToClass.has(primaryClassId)) {
this.targetToClass.set(primaryClassId, new Map());
}
this.targetToClass.get(primaryClassId)!.set(PrimaryCandidateSymbol, classId);
}
}
return config;
}
/**
* Handle uninstalling a class
*/
override onUninstallFinalize(cls: Class): void {
const classId = cls.Ⲑid;
if (!this.classToTarget.has(classId)) {
return;
}
if (this.instances.has(classId)) {
for (const qualifier of this.classToTarget.get(classId)!.keys()) {
this.destroyInstance(cls, qualifier);
}
}
}
/**
* Inject fields into instance
*/
async injectFields<T extends { constructor: Class<T> }>(o: T, cls = o.constructor): Promise<void> {
this.verifyInitialized();
// Compute fields to be auto-wired
return await this.resolveFieldDependencies(this.get(cls), o);
}
/**
* Execute the run method of a given class
*/
async runInstance<T extends { run(..._args: unknown[]): unknown }>(
cls: Class<T>, ...args: Parameters<T['run']>
): Promise<Awaited<ReturnType<T['run']>>> {
await RootRegistry.init();
const inst = await this.getInstance<T>(cls);
return castTo<Awaited<ReturnType<T['run']>>>(inst.run(...args));
}
}
export const DependencyRegistry = new $DependencyRegistry();